Compare commits

66 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
inext 3fb653f4bf errore con individui 2026-06-02 16:28:06 +02:00
inext c3621cebce 2 giugno 2026 2026-06-02 08:00:14 +02:00
inext 04ba48e87b re-check final 2026-06-01 22:55:50 +02:00
inext 7bdbf4e077 DOCKER IMAGE geenration 2026-06-01 19:38:22 +02:00
inext 3424c1c9f7 final stage 2026-06-01 16:11:29 +02:00
inext 5557650824 Nextcloud ok save doc 2026-05-29 06:54:07 +02:00
inext ab038aa6be nextcloud connessione ok gdrive no 2026-05-29 06:23:57 +02:00
inext 4a3051faff modifica - da terminare gestioen doc remoti - resto ok 2026-05-28 13:25:47 +02:00
inext f2b0833b90 gestione documentale avanzata 1 parte 2026-05-28 09:34:28 +02:00
inext 3471befb1a sistemazione reports - cambio password - profilo utente 2026-05-27 10:45:05 +02:00
inext fefc3bba2a fine implementazione multi-sentder 2026-05-27 08:37:37 +02:00
inext b0865368c8 add SMTP sender 2026-05-27 07:29:29 +02:00
inext 982d754857 export calendar - modifica impostazioni 2026-05-26 14:10:04 +02:00
inext 60ebd3b652 finela before clean 2026-05-26 12:59:17 +02:00
37162 changed files with 4326592 additions and 341169 deletions
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
.env
.git
.gitignore
.vite
node_modules
vendor
storage/app/backups
storage/app/documenti
storage/framework/cache
storage/framework/sessions
storage/framework/views
storage/logs
bootstrap/cache
docker-compose.yml
docker-compose.mysql.yml
+12 -29
View File
@@ -1,17 +1,14 @@
APP_NAME=Laravel APP_NAME="Glastree"
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
APP_LOCALE=en APP_LOCALE=it
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=it_IT
APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
@@ -20,14 +17,14 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=sqlite DB_CONNECTION=mysql
# DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
# DB_PORT=3306 DB_PORT=3306
# DB_DATABASE=laravel DB_DATABASE=glastree
# DB_USERNAME=root DB_USERNAME=glastree
# DB_PASSWORD= DB_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=file
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
@@ -35,17 +32,9 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=file
CACHE_STORE=database CACHE_STORE=file
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log MAIL_MAILER=log
MAIL_SCHEME=null MAIL_SCHEME=null
@@ -56,10 +45,4 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
+3
View File
@@ -0,0 +1,3 @@
glastree*.tar.gz
glastree*.tar
.env
-3
View File
@@ -1,3 +0,0 @@
ignore-scripts=true
audit=true
prefix=/home/inext/.npm-global
-1
View File
@@ -1 +0,0 @@
{"version":2,"defects":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":7,"Tests\\Feature\\CalendarEventsTest::test_generate_settimanale_events_produces_weekly_occurrences":7},"times":{"Tests\\Unit\\ExampleTest::test_that_true_is_true":0,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.111,"Tests\\Feature\\CalendarEventsTest::test_find_first_sunday_of_june_2026":0.015,"Tests\\Feature\\CalendarEventsTest::test_find_second_saturday_of_june_2026":0.001,"Tests\\Feature\\CalendarEventsTest::test_find_fifth_sunday_returns_null_when_not_in_month":0.001,"Tests\\Feature\\CalendarEventsTest::test_generate_mensile_events_for_all_months":0.014,"Tests\\Feature\\CalendarEventsTest::test_generate_mensile_events_respects_selected_months":0.002,"Tests\\Feature\\CalendarEventsTest::test_generate_annuale_events_produces_correct_dates":0.002,"Tests\\Feature\\CalendarEventsTest::test_generate_settimanale_events_produces_weekly_occurrences":0.004}}
+1
View File
@@ -133,6 +133,7 @@ Middleware per: X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer
MEMORY.md First: Leggere e aggiornare MEMORY.md prima/dopo ogni azione. È la fonte di verità del progetto. MEMORY.md First: Leggere e aggiornare MEMORY.md prima/dopo ogni azione. È la fonte di verità del progetto.
Mai Hardcoded: Nessun percorso, nome app o URL fisso nel codice. Usa sempre helper e .env. Mai Hardcoded: Nessun percorso, nome app o URL fisso nel codice. Usa sempre helper e .env.
Database Agnostic: Scrivi migration e query compatibili con MySQL e SQLite. Testa con entrambi. Database Agnostic: Scrivi migration e query compatibili con MySQL e SQLite. Testa con entrambi.
JavaScript Blance Check: Dopo ogni modifica a blocchi JS con funzioni annidate, verifica SEMPRE con `node -e '...'` il bilanciamento di `{` e `}`. Leggi anche il contesto circostante per assicurarti che `function` non sia accidentalmente annidata dentro un'altra.
📋 Formato di Risposta Obbligatorio 📋 Formato di Risposta Obbligatorio
-114
View File
@@ -1,114 +0,0 @@
-# AGENTS.md
## System Prompt Senior PHP 8.4 / Laravel 13 / AdminLTE / MySQL (Enterprise)
### 🎯 Ruolo
Sei un **Senior Software Engineer** specializzato in:
- PHP **8.4**
- Laravel **13**
- AdminLTE
- MySQL
- Architetture enterprise, scalabili e manutenibili
Il tuo compito è produrre codice completo, sicuro, scalabile e conforme agli standard professionali.
---
### 🧠 Gestione della memoria (`MEMORY.md`)
#Prima di **ogni azione**, devi:
#
#1. **Leggere `MEMORY.md`** per recuperare il contesto precedente.
#2. **Aggiornare `MEMORY.md`** con:
# - nuove scoperte tecniche,
# - decisioni architetturali,
# - file creati o modificati,
# - dipendenze aggiunte,
# - problemi risolti o aperti,
# - convenzioni adottate.
#
# `MEMORY.md` è la **fonte di verità** del progetto e garantisce continuità e coerenza.
### 🧠 Gestione della memoria (MEMORY.md)
Prima di ogni azione devi:
1. Leggere completamente il file `MEMORY.md` per recuperare il contesto precedente.
2. Utilizzare le informazioni contenute in `MEMORY.md` per garantire continuità, coerenza e allineamento del progetto.
---
### 📌 Regola obbligatoria di persistenza
Dopo ogni richiesta dellutente, lagente deve:
1. Rileggere completamente `MEMORY.md` prima di iniziare qualsiasi operazione.
2. Aggiornare `MEMORY.md` riscrivendolo per intero, includendo:
- nuove informazioni apprese,
- file creati o modificati,
- decisioni architetturali,
- dipendenze aggiunte,
- problemi aperti o risolti,
- stato corrente del progetto,
- qualsiasi contenuto rilevante generato nella risposta.
3. Salvare il nuovo contenuto di `MEMORY.md` nella root del repository.
4. Ogni risposta dellagente deve sempre contenere:
- loutput richiesto dallutente,
- la nuova versione completa e aggiornata di `MEMORY.md`.
Se una sezione di `MEMORY.md` non cambia, deve comunque essere mantenuta.
---
### 🧩 Stile di lavoro
- Produci **file completi**, mai snippet isolati.
- Rispetta **PSR12**, **SOLID**, **Clean Architecture**, **DDD** quando applicabile.
- Usa Laravel 13 con:
- Eloquent ORM,
- Form Request,
- Resource / ResourceCollection,
- Policy + Gate,
- Eventi + Listener,
- Modularizzazione del codice.
- Usa AdminLTE integrato con Blade e asset pipeline. Prendi a riferimento questa pagina per le viste: https://adminlte.io/themes/AdminLTE/index2.html
- Usa MySQL con:
- migrazioni complete,
- foreign key,
- indici ottimizzati,
- naming convention coerente.
---
### 🔐 Sicurezza e qualità
- Valida sempre input e output.
- Evita SQL injection, XSS, CSRF, mass assignment.
- Usa DTO o Resource per lesposizione dati.
- Scrivi test PHPUnit quando richiesto.
- Documenta con PHPDoc e README tecnici.
---
### ⚙️ Configurazione speciale
Imposta sempre:
OPENCODE_EXPERIMENTAL_LSP_TOOL=true
---
### 🧭 Modalità di risposta
Ogni volta che produci output:
1. **Controlla `MEMORY.md`**
2. Aggiorna `MEMORY.md`
3. Genera:
- file completi,
- struttura delle directory,
- istruzioni di installazione,
- comandi artisan,
- note architetturali,
- eventuali script di build.
---
### 🔗 Collegamenti utili
- [Laravel_architettura](ca://s?q=Laravel_architettura)
- [AdminLTE_integrazione](ca://s?q=AdminLTE_integrazione)
- [Pattern_enterprise_PHP](ca://s?q=Pattern_enterprise_PHP)
- [Gestione_MEMORIA_md](ca://s?q=Gestione_MEMORIA_md)
+32
View File
@@ -0,0 +1,32 @@
FROM php:8.4-apache
RUN docker-php-ext-enable opcache \
&& a2enmod rewrite
RUN apt-get update && apt-get install -y libsqlite3-dev libpng-dev libjpeg-dev libwebp-dev libzip-dev \
&& docker-php-ext-configure gd --with-jpeg --with-webp \
&& docker-php-ext-install pdo_mysql pdo_sqlite bcmath gd zip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
&& sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
COPY . /var/www/html/
RUN mkdir -p bootstrap/cache \
&& composer install --no-dev --optimize-autoloader --no-scripts \
&& mkdir -p storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
&& chown -R www-data:www-data storage bootstrap/cache
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["apache2-foreground"]
+404 -530
View File
@@ -1,551 +1,425 @@
# MEMORY.md - Glastree Project # 🧠 MEMORY.md — Stato Progetto
## Project Context ## Obiettivo
Laravel 13 / PHP 8.4 project for parish management (Glastree). Full codebase exists and is operational. App gestionale Laravel 13 con AdminLTE 4 per gestione Persone e Gruppi.
--- ## Funzionalità Implementate
## System Status ### 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
### ✅ FUNZIONANTE (Stable) ### CSV Import Gruppi
- Dashboard - Stesso pattern di `IndividuoController` (import GET, importStore POST, downloadTemplate GET)
- Individui (CRUD completo) - funziona da internet (con viste personalizzate per colonna) - Colonne: nome, descrizione, parent_id, diocesi_id, indirizzo_incontro, cap_incontro, città_incontro, sigla_provincia_incontro
- Gruppi (CRUD completo) - funziona da internet (con vista tabella/albero e colonne personalizzate) - Fault-tolerant: skip righe vuote, log errori, try/catch su ogni riga
- Documenti - funziona da internet
- Eventi (CRUD completo) - funziona da internet (con mass delete)
- Calendario eventi - funziona da internet
- Email (sistema completo)
- Impostazioni (App Settings, Logo, Email Settings)
- Mailing List - funziona da internet (con mass delete)
- Report (predefiniti e personalizzati, accesso tramite permesso 'report')
### ⚠️ IN COMPLETAMENTO ### ICS Import Eventi
- Sistema Email: funziona al 95%, SMTP potrebbe necessitare credenziali corrette - Usa `Sabre\VObject\Reader::read()` già in vendor/
- Nuova implementazione: email sync logging - Mapping: SUMMARY→nome_evento, DESCRIPTION→descrizione, DTSTART→data_specifica+ora_inizio, DTEND→durata_minuti, LOCATION→luogo_indirizzo, UID→uid_esterno
- Dedup via uid_esterno
- RRULE: import come evento singolo con prima occorrenza
--- ### build-dist.sh (script di distribuzione)
- Genera `glastree-YYYYMMDD_HHMM.tar.gz`
- Include: sorgenti, vendor (production)
- Esclude: .git, node_modules, tests, cache, storage content, vendor/docs/tests, Docker
- **Istruzioni post-estrazione complete**: mkdir, permessi, configurazione, cache clear
## Progress Summary ## Tag System
### Eventi Enhancements (Completed) ### Implementazione Completa (Opzione B)
- Migration `2026_05_12_142009_add_location_to_eventi_table` added `luogo_indirizzo` and `luogo_url_maps` columns - **Migration**: `2026_06_08_194155_create_tags_tables.php` — tabelle `tags` + `taggables`
- Evento model updated with fillable fields - **Model `Tag.php`**: morphToMany a 5 entity types, auto-slug su creating, color badge
- EventoController updated: index() with search + sorting, store/update with location fields - **Trait `HasTagsLight.php`**: `tags()` morphToMany, scopes `withAllTags`/`withAnyTags`
- eventi/index.blade.php: search input, sortable columns (nome_evento, created_at), delete button - **5 Models aggiornati**: Individuo, Gruppo, Evento, Documento, MailingList — usano `HasTagsLight`
- eventi/create.blade.php: "Dove" card with address + Google Maps URL fields - **ImpostazioniController**: CRUD completo per Tag (store, update, destroy, reorder) con tab nella pagina Impostazioni
- eventi/edit.blade.php: "Dove" card with address + Google Maps URL fields - **Tag selector**: `_tag-selector.blade.php` — search + pill badges con colori, incluso in create/edit di Individui, Gruppi, Eventi, MailingList
- eventi/show.blade.php: "Luogo" section with address, link to Google Maps, embedded map preview - **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`
### Responsabile Multi-select (Completed) ## Bug Fix Recenti
- Migration `2026_05_12_000003_change_responsabile_to_multi.php` changed `responsabile_id` to `responsabile_ids` JSON
- Gruppo model updated with `getResponsabiliIds()` and `getResponsabili()` methods
- GruppoController updated: index(), store(), update(), show() for multi-select
- Views updated: show, edit, create blades with multi-select support
### 13 Maggio 2026 - Fixes ### 2026-06-07 — Colonne mancanti in eventi e gruppo_individuo
- **Ordinamento colonne**: Aggiunto ordinamento per Descrizione, Tipo, Gruppi, Responsabili - `descrizione_evento` e `is_incontro_gruppo` mancanti in install.sql → creata migration + ALTER TABLE
- **Form edit**: Aggiunti campi `tipo_evento` e `note` mancanti - `ruolo_nel_gruppo` mancante in install.sql per pivot `gruppo_individuo`
- **Show view**: Corretta struttura HTML della sezione documenti - `VistaReport.php`: aggiunto `is_default` a `$fillable`
### 14 Maggio 2026 - Fixes ### 2026-06-08 — build-dist.sh: "provide valid cache path" su server remoto
- **Form annidati**: La sezione Documenti spostata FUORI dal form principale **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.
- **Tipologia enum**: Aggiunti valori 'programma' e 'locandina' alla colonna tipologia in documenti
- **Visibilita enum**: Aggiunto valore 'evento' alla colonna visibilita in documenti
### 15 Maggio 2026 - Eventi Show **Fix**:
- Rimossa sezione "Dove" duplicata dalla sidebar 1. Cambiati exclude patterns per **contenuti soltanto** (non la directory):
- Aggiunta nuova sezione "Luogo" sotto "Data e Orario" nella colonna centrale - `--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`
### 15 Maggio 2026 - Email Settings ## Problema 419 (Page Expired) su Produzione
- Aggiunto pulsante "Test Invio Email (SMTP)" nella pagina impostazioni email Il codice NON ha errori di CSRF — tutte le form hanno `@csrf`. Il problema è configurazione sessione del server:
- Aggiunto metodo `testSmtp()` nel controller per verificare la configurazione SMTP 1. `storage/framework/sessions/` non scrivibile dal web server
- Route: `POST /impostazioni/email/test-smtp` 2. `APP_URL` in `.env` non corrisponde all'URL reale
- Campo password opzionale (non più required) 3. `SESSION_SECURE_COOKIE=true` ma sito in HTTP
- Rimossi attributi required dai campi del form impostazioni email
### 15 Maggio 2026 - Eventi Calendar Soluzione:
- Nuova vista calendario con FullCalendar 6.1.10
- Routes: `GET /eventi/calendar` e `GET /eventi/calendar/events`
- Metodo `calendar()` e `calendarEvents()` in EventoController
- Sidebar: menu Eventi con sottovoci Calendario, Elenco, Nuovo Evento
- Click su evento apre la pagina show dell'evento
### 15 Maggio 2026 - Email UI
- **Compose view**: Rimossa sidebar sinistra, solo form a schermo intero
- **Sidebar menu Email**:
- Ordine: Nuova Email, In arrivo, Preferiti, Inviate, Bozze, Archivio, Cestino
- Cliccando "Email" apre direttamente inbox
- **Bottone sincronizzazione**: "Ricevi/Invia" nella pagina inbox
- **Sincronizzazione**: Limitata a 100 messaggi per cartella
### 15 Maggio 2026 - Bug Fixes
- **RFC 2822**: Usare `Address::create()` di Symfony per gestire correttamente il display name
- **Route email.sync**: Sostituito `route('email.sync.quick')` con `url('email/sync/quick')` nella vista email/index.blade.php
### 16 Maggio 2026 - Email UI Fixes
- **Footer**: Aggiunto `min-height: calc(100vh - 120px)` al content-wrapper per garantire visibilità footer
- **Pagination**: Creato custom view `vendor.pagination.simple-bootstrap-4` con caratteri `‹` e `›` invece di SVG (problema scalabilità risolto)
### 16 Maggio 2026 - Logo Upload System
- **Model**: `App\Models\AppSetting` con metodi `getLogoUrl()`, `getLogoSmallUrl()`, `getAppName()`
- **Migration**: `2026_05_16_000001_add_logo_path_to_app_settings` - aggiunge `logo_path` e `logo_small_path`
- **Controller**: `ImpostazioniController` - metodi `uploadLogo()` e `removeLogo()`
- **Routes**: `POST /impostazioni/logo`, `POST /impostazioni/logo/remove`
- **Views**: Sezione Logo in `/impostazioni` per caricare logo grande e piccolo
- **Login page**: Logo dinamico da `AppSetting::getLogoUrl()`
- **Sidebar**: Logo piccolo da `AppSetting::getLogoSmallUrl()` con fallback a icona
### 16 Maggio 2026 - Email Signature Feature
- **Migration**: `2026_05_16_000003_add_signature_to_email_settings` - aggiunge colonna `signature` (longText)
- **Migration**: `2026_05_16_000004_add_signature_enabled_to_email_settings` - aggiunge colonna `signature_enabled` (boolean, default true)
- **Model**: `EmailSetting` aggiornato con `signature` e `signature_enabled` in fillable, cast boolean, metodo `getSignature()`
- **View**: `admin/email-settings/index.blade.php` aggiornato con:
- Titolo cambiato da "Email IMAP" a "Email Settings"
- Nuova sezione "Firma" nel menu laterale
- Toggle switch "Attiva firma automatica" per abilitare/disabilitare la firma
- Editor WYSIWYG Quill.js per la composizione della firma
- Caricamento lazy di Quill.js da CDN
- **Controller**: `EmailController` aggiornato con metodo `appendSignature(body, signature, enabled)` che aggiunge la firma al body solo se abilitata
- La firma viene aggiunta sia alle email inviate via SMTP che a quelle salvate in "Invio"
- **Sidebar**: Voce di menu cambiata da "Email IMAP" a "Impostazione Email"
---
## Sistema Email - Dettagli Tecnici
### Routes Eventi
```
GET /eventi → eventi.index (elenco)
GET /eventi/calendar → eventi.calendar (calendario)
GET /eventi/calendar/events → eventi.calendar.events (eventi per FullCalendar)
GET /eventi/create → eventi.create
POST /eventi → eventi.store
GET /eventi/{id} → eventi.show
GET /eventi/{id}/edit → eventi.edit
PUT /eventi/{id} → eventi.update
DELETE /eventi/{id} → eventi.destroy
```
### Routes Email
```
POST /email/send → email.send
POST /email/sync/quick → email.sync.quick
POST /email/bulk → email.bulk
GET /email/{folder?} → email.index
POST /email/{id}/star → email.star
DELETE /email/{id} → email.destroy
```
### Folder Types
- inbox, sent, drafts, archive, starred, trash
### SMTP Configuration
- Credenziali salvate in `email_settings` con criptatura
- Password decriptata con `getDecryptedPassword()` o `getDecryptedSmtpPassword()`
- Display name validato con `filter_var($fromName, FILTER_VALIDATE_EMAIL)` - se è un'email usa "Glastree"
### Email Signature
- Campo `signature` in `email_settings` (longText, nullable)
- WYSIWYG editor Quill.js via CDN
- Metodo `appendSignature()` in EmailController aggiunge "---\n" + signature al body
- Applicato sia in `sendViaImap()` che in `storeSentMessage()`
---
## Key Decisions
### Database
- Location uses separate columns: `luogo_indirizzo` (varchar 500) and `luogo_url_maps` (text)
- Map preview via Google Maps embed iframe using the address
- Sortable columns: nome_evento, created_at, descrizione_evento, tipo_recorrenza, gruppi, responsabili
- Multi-select uses Ctrl+click pattern with array input names
### Email
- SMTP defaults to Gmail if not configured
- Email name sanitized to comply with RFC 2822
- Sincronizzazione IMAP limitata a 100 messaggi per cartella
- Signature appended with separator "---\n" before the signature text
### UI
- AdminLTE per tutte le viste
- Sidebar con menu navigazione
- Compose email senza sidebar sinistra
---
## Relevant Files
### Core Controllers
- `app/Http/Controllers/EventoController.php`: eventi with extended sorting
- `app/Http/Controllers/EmailController.php`: email system completo, signature auto-append
- `app/Http/Controllers/Admin/EmailSettingsController.php`: email settings + test SMTP
### Models
- `app/Models/EmailSetting.php`: getDecryptedPassword(), getDecryptedSmtpPassword(), getSignature()
- `app/Models/Evento.php`: fillable with location fields
- `app/Models/Gruppo.php`: getResponsabiliIds(), getResponsabili()
### Views
- `resources/views/admin/email-settings/index.blade.php`: Email settings with signature section (Quill.js WYSIWYG)
- `resources/views/eventi/index.blade.php`: sortable columns
- `resources/views/eventi/edit.blade.php`: fixed nested form, added tipo_evento and note
- `resources/views/eventi/show.blade.php`: removed duplicate "Dove", added "Luogo" section
- `resources/views/email/index.blade.php`: mailbox view con AdminLTE
- `resources/views/email/compose.blade.php`: compose senza sidebar sinistra
- `resources/views/layouts/adminlte.blade.php`: sidebar menu Email con ordine corretto
### Migrations
- `2026_05_16_000003_add_signature_to_email_settings.php`: Adds signature column to email_settings table
---
## Commands Utili
```bash ```bash
# Clear all caches chmod -R 775 storage/framework/sessions
php artisan optimize:clear php artisan config:clear
# Recompile views
php artisan view:cache
# Check routes
php artisan route:list --name=email
``` ```
--- ### 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`.
## Known Issues / TODO **Fix**:
1. SMTP authentication con Infomaniak - verificare credenziali corrette 1. Aggiunto `--exclude='public/storage'` al tar — il symlink va ricreato sul target.
2. Invio email richiede password SMTP corretta nel form impostazioni 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:
### 16 Maggio 2026 - URL Hardcoded Fix (Internet Access)
- **Problema**: Da internet (accesso remoto) molti form e link puntavano a `http://localhost` invece del server remoto
- **Causa**: Uso di `url('/path')` in tutte le view che generava URL basati su `APP_URL=http://localhost`
- **Soluzione**: Sostituzione di tutte le occorrenze di `url('/path')` con path relativi `/path`
- **Files modificati**:
- `individui/edit.blade.php`: form action, link gruppi/documenti, fetch API calls
- `individui/show.blade.php`: link gruppi, documenti, form, fetch API calls
- `gruppi/edit.blade.php`: form action, link individui/documenti, fetch API calls
- `gruppi/show.blade.php`: link individui, eventi
- `eventi/edit.blade.php`: form action, link annulla, documenti
- `eventi/show.blade.php`: breadcrumbs, link gruppi/responsabili, documenti
- `eventi/create.blade.php`: form action, link annulla
- `eventi/index.blade.php`: link create, reset, eventi, gruppi
- `documenti/index.blade.php`: link targets, edit, delete, mass actions, fetch API
- `documenti/edit.blade.php`: form action, download, back link
- `mailing-liste/edit.blade.php`: fetch API calls
- `eventi/calendar.blade.php`: eventSource URL
- **Layout fix**: Aggiunto meta tag `csrf-token` in `adminlte.blade.php` head
---
### 16 Maggio 2026 - Individui Edit Form Fix
- **Problema**: Form non salvava da accesso internet, il pulsante submit non funzionava
- **Causa**: Il button type="submit" non triggherava il form submission
- **Soluzione**:
- Cambiato button da `type="submit"` a `type="button"` con `onclick="document.getElementById('main-form').submit()"`
- Path relativi per tutti i form action
- CSRF token in meta tag del layout
---
### 17 Maggio 2026 - Bulk Action Error Fix
- **Problema**: Azioni massive sulle email (sposta nel cestino) mostravano popup "errore" generico
- **Causa**: `authorizeWrite()` lancia eccezione 403 HTML invece di JSON per richieste AJAX
- **Soluzione**: Aggiunto try-catch in `bulkAction()`, `markRead()` e `toggleStar()` per ritornare JSON con messaggio appropriato
### 17 Maggio 2026 - Nginx Proxy Manager Config
- **Problema**: Email non funziona da internet (dietro NPM)
- **bootstrap/app.php**: Aggiunto `$middleware->trustProxies(at: '*')`
- **Configurazione .env**:
```
APP_URL=
APP_PROTOCOL=https
FORCE_HTTPS=true
TRUSTED_PROXIES=*
```
- **Middleware rimosso**: ForceHttps non necessario - NPM gestisce terminazione HTTPS
- **Advanced Tab NPM**: Abilitare X-Forwarded-Proto e headers proxy
### 17 Maggio 2026 - Form Annidati Fix
- **Problema**: Contatti, gruppi e documenti in individui/edit non venivano salvati
- **Causa**: Sezioni fuori dal main-form (chiusura prematura)
- **Soluzione**: Spostata chiusura form dopo tutte le sezioni, aggiunto pulsante "Salva Tutte le Modifiche"
- **Contatti**: Aggiunto pulsante salva nella riga nuova (icona💾)
### 17 Maggio 2026 - Bulk Action Email Fix
- **Problema**: Azioni massive su email mostravano popup "errore"
- **Causa**: authorizeWrite() lancia eccezione 403 HTML invece di JSON
- **Soluzione**: try-catch in bulkAction(), markRead(), toggleStar()
---
(Last updated: 25 Maggio 2026 - Fix export CSV individui)
### 18 Maggio 2026 - AGENTS.md Update
- Aggiunta regola "Verifica Salvataggio" nelle Regole Operative AI
- Obbligo di verificare SEMPRE l'avvenuta riuscita dopo operazioni di modifica (store, update, delete, upload, sync)
### 25 Maggio 2026 - IndividuoController::elimina() Method
- **Problema**: Errore 500 "Call to undefined method App\Http\Controllers\IndividuoController::elimina()" su richiesta `POST /individui/{id}/elimina`
- **Causa**: Route definita in `routes/web.php:58` ma metodo `elimina()` non implementato nel controller
- **Soluzione**: Aggiunto metodo `elimina()` in `IndividuoController` che:
- Verifica autorizzazione con `authorizeDelete('individui')`
- Riceve parametri `elimina_contatti` e `elimina_documenti` dal form
- Elimina opzionalmente i contatti collegati se richiesto
- Elimina opzionalmente i documenti collegati (con rimozione file fisico) se richiesto
- Elimina l'individuo e redirect alla lista con messaggio di successo
- **Files modificati**:
- `app/Http/Controllers/IndividuoController.php`: aggiunto metodo `elimina()` dopo `destroy()`
### 25 Maggio 2026 - Fix Eliminazione Singola e Massiva
- **Problema**: L'eliminazione non veniva eseguita né per il singolo né per la mass action
- **Cause**:
1. `$request->boolean()` interpretava male i valori `'true'`/`'false'` come stringhe
2. `deleteSelected()` faceva submit multipli di form separati causando redirect concorrenti
- **Soluzione**:
1. Sostituito `$request->boolean()` con `filter_var(..., FILTER_VALIDATE_BOOLEAN)` per parsing corretto
2. Aggiunto metodo `massElimina()` in `IndividuoController` per gestire eliminazione multipla con singolo form
3. Aggiunta route `POST /individui/mass-elimina` in `routes/web.php`
4. Rifattorizzata funzione JS `deleteSelected()` per usare singolo form con array `ids[]`
- **Files modificati**:
- `app/Http/Controllers/IndividuoController.php`: corretto `elimina()`, aggiunto `massElimina()`
- `routes/web.php`: aggiunta route `individui/mass-elimina`
- `resources/views/individui/index.blade.php`: rifattorizzata `deleteSelected()`
### 25 Maggio 2026 - Fix Export CSV Individui
- **Problema**: "Esporta Tutti CSV" e "Esporta Selezionati CSV" non funzionavano
- **Cause**:
1. `exportCSV()` usava `:visible` (selettore jQuery) con `querySelectorAll` (JS nativo) che non lo supporta
2. Export client-side esportava solo righe nel DOM (pagina corrente con pagination), non tutti i record
3. Dati limitati a 5 colonne senza contatti email/telefono
- **Soluzione**:
1. Creato metodo `exportCSV()` server-side in `IndividuoController` con `StreamedResponse`
2. Export completo con: Codice, Cognome, Nome, Data Nascita, Genere, Email, Telefono, Cellulare, Indirizzo, CAP, Città, Provincia, Note
3. Email/Telefono/Cellulare recuperati dai contatti con preferenza per quelli primari
4. Supporto per export tutti o solo selezionati tramite parametro `ids[]`
5. Funzioni JS `exportCSV()` e `exportSelectedCSV()` ora puntano a `/individui/export`
- **Files modificati**:
- `app/Http/Controllers/IndividuoController.php`: aggiunto metodo `exportCSV()` con StreamedResponse
- `routes/web.php`: aggiunta route `GET /individui/export`
- `resources/views/individui/index.blade.php`: semplificate funzioni `exportCSV()` e `exportSelectedCSV()`
(Last updated: 25 Maggio 2026 - Report system, mass actions, gruppi deletion fix)
### 25 Maggio 2026 - Fix Eliminazione Gruppi Padre (Superadmin Only)
- **Problema**: Eliminando un gruppo padre si otteneva pagina 404. Inoltre l'eliminazione di gruppi con figli era disponibile per tutti
- **Cause**:
1. Mancato detach delle tabelle pivot (`gruppo_individuo`, `eventi_gruppi`) prima della cancellazione
2. Nessun controllo per limitare l'eliminazione di gruppi con sottogruppi al superadmin
- **Soluzione**:
1. Rifattorizzato `destroy()` in `GruppoController`: aggiunta verifica `isSuperAdmin()` per gruppi con figli
2. Creato metodo `destroyGruppo()` helper che esegue detach pivot, cancella documenti fisici e poi cancella il gruppo
3. Aggiornata vista `tree-item.blade.php`: pulsante elimina disabilitato per non-superadmin su gruppi con figli
- **Files modificati**:
- `app/Http/Controllers/GruppoController.php`: rifattorizzato `destroy()`, aggiunto `destroyGruppo()`
- `resources/views/gruppi/partials/tree-item.blade.php`: pulsante elimina condizionato a superadmin per gruppi padre
### 25 Maggio 2026 - Mass Actions Eventi e Mailing List
- **Problema**: Eventi e Mailing List non avevano funzionalità di eliminazione massiva
- **Soluzione**:
1. Aggiunti checkbox di selezione, pulsante "Elimina Selezionati" e modal di conferma in `eventi/index.blade.php`
2. Aggiunti checkbox di selezione, pulsante "Elimina Selezionati" e modal di conferma in `mailing-liste/index.blade.php`
3. Aggiunto metodo `massElimina()` in `EventoController` con detach di gruppi e responsabili
4. Aggiunto metodo `massElimina()` in `MailingListController` con cancellazione contatti associati
5. Aggiunte route `POST /eventi/mass-elimina` e `POST /mailing-liste/mass-elimina`
- **Files modificati**:
- `app/Http/Controllers/EventoController.php`: aggiunto `massElimina()`
- `app/Http/Controllers/MailingListController.php`: aggiunto `massElimina()`
- `routes/web.php`: aggiunte route mass-elimina per eventi e mailing-liste
- `resources/views/eventi/index.blade.php`: aggiunta selezione massiva, modal conferma, JS
- `resources/views/mailing-liste/index.blade.php`: aggiunta selezione massiva, modal conferma, JS
### 25 Maggio 2026 - Rimozione Menu Utilità Viste
- **Modifica**: Rimosso dal sidebar menu la voce "Utilità Viste" (`viste.index`)
- **Nota**: Le route e i controller per le viste rimangono funzionanti, solo il link sidebar è stato rimosso temporaneamente
### 25 Maggio 2026 - Sistema Report Completo
- **Funzionalità**: Nuova pagina Report accessibile solo dal superadmin con:
1. **Panoramica database**: statistiche rapide (individui, gruppi, eventi, documenti, mailing list, contatti)
2. **12 Report predefiniti**:
- Individui per Genere
- Individui per Fascia d'Età
- Gerarchia Gruppi
- Gruppi e Membri
- Eventi del Mese
- Documenti per Tipologia
- Contatti per Tipo
- Dettaglio Mailing List
- Individui senza Contatti
- Individui senza Gruppo
- Eventi per Gruppo
- Documenti Orfani
3. **Report personalizzati**: creazione di report custom con selezione tipo dato, colonne, ordinamento e limite
4. **Export CSV**: ogni report può essere esportato in CSV
- **Files creati**:
- `app/Http/Controllers/ReportController.php`: controller completo con 12 report + custom
- `app/Models/ReportCustom.php`: modello per report personalizzati
- `database/migrations/2026_05_25_000001_create_report_custom_table.php`: migration tabella report_custom
- `resources/views/report/index.blade.php`: pagina principale report con card e form creazione
- `resources/views/report/result.blade.php`: pagina visualizzazione risultati report
- **Routes aggiunte**:
- `GET /report` → report.index
- `GET /report/run` → report.run
- `GET /report/export` → report.export
- `GET /report/custom/{id}` → report.run-custom
- `POST /report/custom` → report.store-custom
- `DELETE /report/custom/{id}` → report.destroy-custom
- **Sidebar aggiornata**: link "Report" ora punta a `route('report.index')` con evidenziazione active
(Last updated: 25 Maggio 2026 - Fix sidebar @endif orfano, verifica completa sintassi)
### 25 Maggio 2026 - Fix Sidebar Errore Sintassi
- **Problema**: Errore "syntax error, unexpected token 'endif'" nella dashboard
- **Causa**: Dopo aver rimosso il blocco `@if(Auth::user()->isSuperAdmin())` dalla sidebar per sostituirlo con `@if(Auth::user()->canAccess('report'))`, erano rimasti un `@endif` orfano (linea 237) e un `</li>` extra (linea 239)
- **Soluzione**:
1. Rimosso `@endif` orfano
2. Rimosso `</li>` extra
3. Aggiunto nuovo `@if(Auth::user()->isSuperAdmin())` per proteggere i menu Impostazioni e Admin
- **Verifica completa**: Scansionati tutti i blade file del progetto - tutte le direttive `@if/@endif`, `@foreach/@endforeach`, `@php/@endphp` sono correttamente bilanciate. Compilazione viste OK.
### 25 Maggio 2026 - Accesso Report Basato su Permessi
- **Modifica**: Il modulo 'report' è stato aggiunto a `User::MODULES` come nuovo modulo permissionabile
- **Funzionalità**: Gli utenti con permesso `canAccess('report')` (livello lettura o superiore) possono accedere alla pagina Report, non più solo il superadmin
- **Files modificati**:
- `app/Models/User.php`: aggiunto 'report' a `MODULES` = ['individui', 'gruppi', 'eventi', 'documenti', 'mailing', 'viste', 'report']
- `app/Http/Controllers/ReportController.php`: sostituiti tutti i check `isSuperAdmin()` con `canAccess('report')`
- `resources/views/layouts/adminlte.blade.php`: sidebar mostra "Report" se `canAccess('report')` invece di `isSuperAdmin()`
- **Come abilitare un utente**: Dal pannello Admin → Utenti, assegnare permesso di livello ≥ 1 (Lettura) per il modulo 'report'
### 25 Maggio 2026 - Viste Personalizzate per Colonne (Individui e Gruppi)
- **Funzionalità**: Ogni utente può personalizzare quali colonne visualizzare nelle liste di Individui e Gruppi
- **Individui**:
- Colonne disponibili: Codice, Cognome, Nome, Email, Telefono
- Il controller `IndividuoController@index()` ora passa `$allColumns` e `$visibleColumns` alla vista
- Le colonne visibili sono determinate dalla `VistaReport` predefinita dell'utente
- Modal "Colonne Visibili" mostra tutte le colonne con checkbox per toggle
- Modal "Salva Vista" permette di salvare una vista con nome, colonne, e opzione predefinita
- **Gruppi**:
- Nuova vista tabella affiancata alla vista albero esistente
- Toggle "Tabella/Albero" nella card header
- Colonne disponibili: Nome, Descrizione, Diocesi, Livello, Gruppo Padre, Membri, Responsabili, Indirizzo, Città
- Le colonne visibili sono determinate dalla `VistaReport` predefinita per tipo 'gruppi'
- Salvataggio vista dedicato con route `POST /gruppi/save-vista`
- Eliminazione vista con route `DELETE /gruppi/vista/{id}`
- **Model utilizzato**: `VistaReport` (tabella `viste_report`) con campi: `user_id`, `nome`, `tipo`, `colonne_visibili` (JSON), `is_default`
- **Files modificati**:
- `app/Http/Controllers/IndividuoController.php`: `index()` passa `$allColumns` e `$visibleColumns`
- `app/Http/Controllers/GruppoController.php`: `index()` rifattorizzato per vista tabella/albero, aggiunti `saveVista()` e `deleteVista()`
- `routes/web.php`: aggiunte route `gruppi/save-vista` e `gruppi/delete-vista`
- `resources/views/individui/index.blade.php`: colonne condizionali con `@if(in_array(..., $visibleColumns))`
- `resources/views/gruppi/index.blade.php`: riscritta completamente con doppio mode (tabella/albero), modal salvataggio vista, toggle colonne
### 25 Maggio 2026 - Fix Vari Pagine (Individui, Eventi, Mailing, Documenti Upload)
- **Individui/Gruppi**: Errore `$tableColumns` non definito risolto usando `$allColumns`
- **Eventi**: Checkbox righe senza `id` impedivano il corretto funzionamento del label; aggiunto `id="row-checkbox-{{ $evento->id }}"`
- **Mailing List**: Stesso problema checkbox, corretto con `id` univoci
- **Documenti upload**: Funzione `loadUploadTargets()` chiamata ma non definita; aggiunta per popolare il dropdown target
### 25 Maggio 2026 - Fix Documento Avatar e Morph Map
- **Problema**: Caricando un documento avatar associato a un individuo, errore "Class 'individuo' not found"
- **Causa**: Il campo `visibilita_target_type` nel database conteneva nomi brevi ('individuo', 'gruppo') invece del namespace completo
- **Soluzione**:
1. Registrata `Relation::morphMap()` in `AppServiceProvider@boot()` per mappare nomi brevi a classi complete
2. Aggiornato `DocumentoController@store()` per impostare correttamente `visibilita_target_type` in base alla visibilità
- **Files modificati**:
- `app/Providers/AppServiceProvider.php`: aggiunta morphMap per individuo, gruppo, evento, mailing, associazione, federazione
- `app/Http/Controllers/DocumentoController.php`: logica esplicita per mappare visibilità → target_type
### 25 Maggio 2026 - Fix Pagina Gruppi (Tabella Pivot Inesistente)
- **Problema**: Pagina gruppi falliva con "Table 'glastree.gruppo_responsabili' doesn't exist"
- **Causa**: Il modello `Gruppo` aveva una relazione `responsabili()` che puntava a tabella pivot inesistente (la migration `2026_05_12_000003` aveva migrato a JSON `responsabile_ids`)
- **Soluzione**:
1. Rimossa relazione `responsabili()` da `Gruppo.php`
2. Rimossa eager loading `responsabili` da `GruppoController@index()`
3. Mantenuti metodi helper `getResponsabiliIds()` e `getResponsabili()` che leggono dal campo JSON
4. Corretti riferimenti in `gruppi/show.blade.php` e `individui/show.blade.php` per usare `getResponsabili()`
- **Files modificati**:
- `app/Models/Gruppo.php`: rimossa relazione `responsabili()`, mantenuti helper
- `app/Http/Controllers/GruppoController.php`: rimossa eager loading `responsabili`
- `resources/views/gruppi/show.blade.php`: corretto `$child->responsabile` → `$child->getResponsabili()->first()`
- `resources/views/individui/show.blade.php`: corretto `$gruppo->responsabile` → `$gruppo->getResponsabili()->first()`
### 25 Maggio 2026 - Fix Caricamento Documenti in Pagina Gruppi
- **Funzionalità**: Aggiunto modale "Carica Documento" nella pagina `gruppi/show` per upload diretto
- **Logica Responsabili**: Solo i membri del gruppo possono essere selezionati come responsabili
- In `create.blade.php`: il dropdown responsabili si aggiorna dinamicamente quando si aggiungono/rimuovono membri
- Funzione JS `updateResponsabiliFromMembri()` sincronizza il select responsabili con i membri aggiunti
- **Files modificati**:
- `resources/views/gruppi/show.blade.php`: aggiunto modale upload documento, alert success/error
- `resources/views/gruppi/create.blade.php`: logica JS per vincolare responsabili ai soli membri
(Last updated: 25 Maggio 2026 - Fix report eventi_calendario, colonna data)
### 25 Maggio 2026 - Fix Report Eventi Calendario
- **Problema**: Report "Eventi del Mese" falliva con "Unknown column 'data_inizio' in 'WHERE'"
- **Causa**: Il report usava `data_inizio` che non esiste nella tabella `eventi`. La colonna corretta è `data_specifica`
- **Soluzione**: Sostituito `data_inizio` con `data_specifica` in `reportEventiCalendario()` e `reportEventiPerGruppo()`
- **Files modificati**:
- `app/Http/Controllers/ReportController.php`: righe 389-397 e 532, corretto nome colonna
(Last updated: 25 Maggio 2026 - Fix report eventi_calendario, colonna data)
### 25 Maggio 2026 - Fix Gruppi Table Hierarchy
- **Problema**: La visualizzazione a tabella dei gruppi non mostrava correttamente la gerarchia padre-figlio
- **Soluzione**:
- Aggiunto metodo `sortGruppiHierarchically()` in `GruppoController` per ordinare i gruppi in modo gerarchico
- La tabella ora mostra prima i gruppi root, poi i loro figli in ordine annidato
- Aggiunta eager loading `avatar` per supportare il futuro avatar dei gruppi
- **Files modificati**:
- `app/Http/Controllers/GruppoController.php`: aggiunto `sortGruppiHierarchically()`, `addChildrenRecursive()`, aggiunto 'avatar' a eager loading
### 25 Maggio 2026 - Group Avatar Support
- **Funzionalità**: I gruppi ora possono avere un avatar/logo come gli individui
- **Implementazione**:
- Aggiunto metodo `avatar()` in `Gruppo.php` (relazione HasOne a Documento con tipologia 'avatar')
- Aggiunto accessor `getAvatarUrlAttribute()` per ottenere l'URL dell'avatar
- Eager loading `avatar` aggiunto in `GruppoController@index()`
- View `gruppi/index.blade.php` aggiornata per mostrare l'avatar nella tabella
- View `gruppi/show.blade.php` aggiornata per mostrare l'avatar del gruppo
- View `gruppi/create.blade.php` e `edit.blade.php` aggiornate per permettere upload avatar
- **Files modificati**:
- `app/Models/Gruppo.php`: aggiunto `avatar()`, `getAvatarUrlAttribute()`
- `app/Http/Controllers/GruppoController.php`: aggiunto eager loading `avatar`
- `resources/views/gruppi/index.blade.php`: colonna avatar nella tabella
- `resources/views/gruppi/show.blade.php`: avatar nella header card
- `resources/views/gruppi/create.blade.php` e `edit.blade.php`: upload avatar
### 26 Maggio 2026 - Event Types System (Tipologie Eventi)
- **Nuova tabella**: `tipologie_eventi` con migration + seed dati default (catechesi, liturgia, animazione, formazione, incontro, riunione, festa, altro)
- **Nuovo modello**: `App\Models\TipologiaEvento` — identico pattern a `TipologiaDocumento` e `Ruolo`:
- `$fillable`: nome, descrizione, ordine, attiva
- Metodi: `eventi()` (hasMany), `attive()`, `opzioni()`
- FK via `nome` (string) → `eventi.tipo_evento` (stesso pattern Documento.tipologia → TipologiaDocumento.nome)
- **Admin CRUD**: 4 nuovi metodi in `ImpostazioniController`: `tipologieEventiStore()`, `tipologieEventiUpdate()`, `tipologieEventiDestroy()`, `tipologieEventiReorder()`
- **Routes** (5 nuove): `/impostazioni/tipologie-eventi/*`
- **Impostazioni view**: Nuovo tab `#tipologie-eventi` con tabella sortabile, modali add/edit, stessi pattern esistenti
- **Sidebar**: Nuovo link "Tipologie Eventi" sotto Impostazioni (solo superadmin)
- **Evento model**: Aggiunta relazione `tipologiaEvento()` (BelongsTo via 'nome')
- **EventoController**: Validazione cambiata da `nullable|string|max:100` a `nullable|exists:tipologie_eventi,nome` in store() e update(); create/edit passano `$tipologieEventi` alla view
- **Views eventi**:
- `create.blade.php`: Dropdown "Tipo Evento" dopo Descrizione Breve
- `edit.blade.php`: Text libero → dropdown; fallback per vecchi valori non presenti
- `show.blade.php`: Badge "Tipologia" nella sezione Data e Orario; colonna rinominata "Ricorrenza"
- `index.blade.php`: Nuova colonna sortabile "Tipologia" con badge; colonna "Tipo" → "Ricorrenza"; colspan 8→9
- **Files creati**:
- `database/migrations/2026_05_26_000001_create_tipologie_eventi_table.php`
- `app/Models/TipologiaEvento.php`
- **Files modificati**:
- `app/Http/Controllers/ImpostazioniController.php`
- `app/Http/Controllers/EventoController.php`
- `app/Models/Evento.php`
- `routes/web.php`
- `resources/views/impostazioni/index.blade.php` - `resources/views/impostazioni/index.blade.php`
- `resources/views/layouts/adminlte.blade.php` - `resources/views/admin/email-settings/index.blade.php`
- `resources/views/eventi/create.blade.php` - HTML invalido: il browser chiudeva implicitamente il form SAVE all'apertura del form DELETE, causando l'invio alla route sbagliata.
- `resources/views/eventi/edit.blade.php`
- `resources/views/eventi/show.blade.php`
- `resources/views/eventi/index.blade.php`
(Last updated: 26 Maggio 2026 - Event Types system) **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.
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\BackupService;
use Illuminate\Console\Command;
class BackupRunCommand extends Command
{
protected $signature = 'backup:run
{--filename= : Custom filename prefix for the backup file}
{--no-files : Exclude uploaded files from backup}
{--no-env : Exclude .env from backup}';
protected $description = 'Esegue un backup completo del database, file e configurazione';
private BackupService $backupService;
public function __construct(BackupService $backupService)
{
parent::__construct();
$this->backupService = $backupService;
}
public function handle(): int
{
$this->info('Avvio backup...');
if ($this->option('no-files')) {
$this->backupService->saveConfig(['backup_include_files' => false]);
$this->warn('Files esclusi dal backup.');
}
if ($this->option('no-env')) {
$this->backupService->saveConfig(['backup_include_env' => false]);
$this->warn('.env escluso dal backup.');
}
$result = $this->backupService->run();
if ($result['success']) {
$this->info(' Backup completato con successo!');
$this->line('File: ' . $result['filename']);
$this->line('Dimensione: ' . $result['size_formatted']);
$this->line('Percorso: ' . storage_path('app/backups/' . $result['filename']));
return Command::SUCCESS;
}
$this->error(' Backup fallito: ' . ($result['message'] ?? 'Errore sconosciuto'));
return Command::FAILURE;
}
}
+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;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AppSetting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Storage;
class ExportHelpPdf extends Command
{
protected $signature = 'help:export-pdf {--output= : Output path for the PDF file}';
protected $description = 'Genera il PDF della guida completa';
public function handle(): int
{
$appName = AppSetting::getAppName() ?? 'Glastree';
$this->components->info('Generazione HTML della guida...');
$html = View::make('help.pdf', ['appName' => $appName])->render();
$tmpHtml = tempnam(sys_get_temp_dir(), 'help_') . '.html';
file_put_contents($tmpHtml, $html);
$output = $this->option('output');
if (!$output) {
$output = storage_path('app/help-guida.pdf');
}
$this->components->info('Conversione in PDF via Chromium...');
$cmd = sprintf(
'/usr/bin/chromium --headless --no-sandbox --disable-gpu --print-to-pdf=%s %s 2>&1',
escapeshellarg($output),
escapeshellarg('file://' . $tmpHtml)
);
exec($cmd, $execOutput, $exitCode);
unlink($tmpHtml);
if ($exitCode !== 0) {
$this->components->error('Errore Chromium: ' . implode("\n", $execOutput));
return Command::FAILURE;
}
$size = filesize($output);
$this->components->success(sprintf(
'PDF generato: %s (%.2f MB)',
$output,
$size / 1024 / 1024
));
return Command::SUCCESS;
}
}
@@ -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;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
class SyncUserPermissions extends Command
{
protected $signature = 'users:sync-permissions';
protected $description = 'Sync all users permissions with MODULES, adding missing keys with default 0';
public function handle(): int
{
$count = 0;
foreach (User::all() as $user) {
$permissions = $user->permissions ?? [];
$changed = false;
foreach (User::MODULES as $module) {
if (!array_key_exists($module, $permissions)) {
$permissions[$module] = User::LEVEL_NONE;
$changed = true;
}
}
if ($changed) {
$user->permissions = $permissions;
$user->save();
$this->info("Synced permissions for user ID {$user->id} ({$user->email})");
$count++;
}
}
$this->info("Done. {$count} users updated.");
return self::SUCCESS;
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace App\Helpers;
class ReportColumnRegistry
{
private static array $columns = [];
public static function getColumns(string $entityType): array
{
if (empty(self::$columns)) {
self::boot();
}
return self::$columns[$entityType] ?? [];
}
public static function allEntityTypes(): array
{
if (empty(self::$columns)) {
self::boot();
}
return array_keys(self::$columns);
}
public static function getColumnLabel(string $entityType, string $column): ?string
{
$cols = self::getColumns($entityType);
return $cols[$column]['label'] ?? null;
}
public static function resolveRelationValue(object $item, string $column): mixed
{
$parts = explode('.', $column);
if (count($parts) === 1) {
return $item->{$column} ?? '';
}
$current = $item;
for ($i = 0; $i < count($parts); $i++) {
$part = $parts[$i];
if ($current === null || $current === '') {
return '';
}
if ($current instanceof \Illuminate\Support\Collection) {
$remaining = array_slice($parts, $i);
$filterKey = $remaining[0] ?? null;
if ($filterKey && $current->isNotEmpty() && $current->first()->getAttribute('tipo') !== null) {
return $current->where('tipo', $filterKey)
->pluck('valore')
->filter()
->implode(', ');
}
$pluckKey = implode('.', $remaining);
return $current->pluck($pluckKey)->filter()->implode(', ');
}
if ($current instanceof \Illuminate\Database\Eloquent\Model) {
try {
$relation = $current->{$part};
if ($relation instanceof \Illuminate\Support\Collection) {
$current = $relation;
} elseif ($relation instanceof \Illuminate\Database\Eloquent\Model) {
$current = $relation;
} else {
$current = $relation;
}
} catch (\Exception) {
try {
$current = $current->getAttribute($part) ?? '';
} catch (\Exception) {
return '';
}
}
} elseif (is_array($current)) {
$current = $current[$part] ?? '';
} elseif (is_object($current)) {
$current = $current->{$part} ?? '';
} else {
return '';
}
}
if ($current instanceof \Carbon\Carbon) {
return $current->format('d/m/Y');
}
if ($current instanceof \Illuminate\Support\Collection) {
return $current->implode(', ');
}
if (is_bool($current)) {
return $current ? 'Si' : 'No';
}
return $current ?? '';
}
private static function boot(): void
{
self::$columns = [
'individui' => [
'id' => ['label' => 'ID', 'type' => 'direct'],
'codice_id' => ['label' => 'Codice', 'type' => 'direct'],
'cognome' => ['label' => 'Cognome', 'type' => 'direct'],
'nome' => ['label' => 'Nome', 'type' => 'direct'],
'nome_completo' => ['label' => 'Nome Completo', 'type' => 'accessor'],
'data_nascita' => ['label' => 'Data Nascita', 'type' => 'direct'],
'genere' => ['label' => 'Genere', 'type' => 'direct'],
'indirizzo' => ['label' => 'Indirizzo', 'type' => 'direct'],
'cap' => ['label' => 'CAP', 'type' => 'direct'],
'città' => ['label' => 'Città', 'type' => 'direct'],
'sigla_provincia' => ['label' => 'Provincia', 'type' => 'direct'],
'tipo_documento' => ['label' => 'Tipo Documento', 'type' => 'direct'],
'numero_documento' => ['label' => 'Numero Documento', 'type' => 'direct'],
'scadenza_documento' => ['label' => 'Scadenza Documento', 'type' => 'direct'],
'note' => ['label' => 'Note', 'type' => 'direct'],
'created_at' => ['label' => 'Data Creazione', 'type' => 'direct'],
'updated_at' => ['label' => 'Ultimo Aggiornamento', 'type' => 'direct'],
'email_primaria' => ['label' => 'Email Primaria', 'type' => 'accessor'],
'telefono_primario' => ['label' => 'Telefono Primario', 'type' => 'accessor'],
'contatti.email' => ['label' => 'Email (da contatti)', 'type' => 'relation'],
'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' => [
'id' => ['label' => 'ID', 'type' => 'direct'],
'nome' => ['label' => 'Nome', 'type' => 'direct'],
'descrizione' => ['label' => 'Descrizione', 'type' => 'direct'],
'parent_id' => ['label' => 'ID Gruppo Padre', 'type' => 'direct'],
'diocesi_id' => ['label' => 'ID Diocesi', 'type' => 'direct'],
'indirizzo_incontro' => ['label' => 'Indirizzo Incontro', 'type' => 'direct'],
'cap_incontro' => ['label' => 'CAP Incontro', 'type' => 'direct'],
'città_incontro' => ['label' => 'Città Incontro', 'type' => 'direct'],
'sigla_provincia_incontro' => ['label' => 'Provincia Incontro', 'type' => 'direct'],
'mappa_posizione' => ['label' => 'Mappa', 'type' => 'direct'],
'created_at' => ['label' => 'Data Creazione', 'type' => 'direct'],
'updated_at' => ['label' => 'Ultimo Aggiornamento', 'type' => 'direct'],
'full_path' => ['label' => 'Percorso Completo', 'type' => 'accessor'],
'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' => [
'id' => ['label' => 'ID', 'type' => 'direct'],
'nome_evento' => ['label' => 'Nome Evento', 'type' => 'direct'],
'descrizione_evento' => ['label' => 'Descrizione', 'type' => 'direct'],
'tipo_evento' => ['label' => 'Tipo Evento', 'type' => 'direct'],
'tipo_recorrenza' => ['label' => 'Ricorrenza', 'type' => 'direct'],
'data_specifica' => ['label' => 'Data', 'type' => 'direct'],
'ora_inizio' => ['label' => 'Ora Inizio', 'type' => 'direct'],
'durata_minuti' => ['label' => 'Durata (minuti)', 'type' => 'direct'],
'luogo_indirizzo' => ['label' => 'Luogo', 'type' => 'direct'],
'luogo_url_maps' => ['label' => 'URL Mappe', 'type' => 'direct'],
'note' => ['label' => 'Note', 'type' => 'direct'],
'is_incontro_gruppo' => ['label' => 'Incontro Gruppo', 'type' => 'direct'],
'created_at' => ['label' => 'Data Creazione', 'type' => 'direct'],
'periodicita_label' => ['label' => 'Periodicità', 'type' => 'accessor'],
'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' => [
'id' => ['label' => 'ID', 'type' => 'direct'],
'nome_file' => ['label' => 'Nome File', 'type' => 'direct'],
'tipologia' => ['label' => 'Tipologia', 'type' => 'direct'],
'visibilita' => ['label' => 'Visibilità', 'type' => 'direct'],
'mime_type' => ['label' => 'Tipo MIME', 'type' => 'direct'],
'dimensione' => ['label' => 'Dimensione (bytes)', 'type' => 'direct'],
'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' => [
'id' => ['label' => 'ID', 'type' => 'direct'],
'tipo' => ['label' => 'Tipo', 'type' => 'direct'],
'valore' => ['label' => 'Valore', 'type' => 'direct'],
'etichetta' => ['label' => 'Etichetta', 'type' => 'direct'],
'is_primary' => ['label' => 'Primario', 'type' => 'direct'],
'created_at' => ['label' => 'Data Creazione', 'type' => 'direct'],
'individuo.nome_completo' => ['label' => 'Individuo', 'type' => 'relation'],
'individuo.codice_id' => ['label' => 'Codice Individuo', 'type' => 'relation'],
],
];
}
}
@@ -39,4 +39,35 @@ class ActivityLogController extends Controller
return view('admin.activity-logs.index', compact('logs', 'users', 'modules')); 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.");
}
} }
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AppSetting;
use App\Services\BackupService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BackupController extends Controller
{
private BackupService $backupService;
public function __construct(BackupService $backupService)
{
$this->backupService = $backupService;
}
public function index(): View
{
$backups = $this->backupService->list();
$config = $this->backupService->getConfig();
return view('admin.backup.index', compact('backups', 'config'));
}
public function run(): RedirectResponse
{
$result = $this->backupService->run();
if ($result['success']) {
return redirect()->route('admin.backup.index')
->with('success', 'Backup completato: ' . ($result['filename'] ?? '') . ' (' . ($result['size_formatted'] ?? '') . ')');
}
return redirect()->route('admin.backup.index')
->with('error', $result['message'] ?? 'Errore durante il backup.');
}
public function download(string $filename)
{
$filepath = $this->backupService->download($filename);
if (!$filepath) {
return redirect()->route('admin.backup.index')
->with('error', 'File di backup non trovato.');
}
return response()->download($filepath)->deleteFileAfterSend(false);
}
public function destroy(string $filename): RedirectResponse
{
if ($this->backupService->delete($filename)) {
return redirect()->route('admin.backup.index')
->with('success', 'Backup "' . $filename . '" eliminato.');
}
return redirect()->route('admin.backup.index')
->with('error', 'Impossibile eliminare il backup.');
}
public function updateConfig(Request $request): RedirectResponse
{
$validated = $request->validate([
'backup_path' => 'nullable|string|max:255',
'backup_retention_days' => 'nullable|integer|min:1|max:365',
'backup_include_files' => 'nullable|boolean',
'backup_include_env' => 'nullable|boolean',
]);
$this->backupService->saveConfig($validated);
return redirect()->route('admin.backup.index')
->with('success', 'Configurazione backup salvata.');
}
public function toggleAuto(): RedirectResponse
{
$enabled = !AppSetting::getSetting('backup_auto_enabled', false);
$setting = AppSetting::first() ?? new AppSetting();
$setting->backup_auto_enabled = $enabled;
$setting->save();
$msg = $enabled ? 'Backup automatico abilitato.' : 'Backup automatico disabilitato.';
return redirect()->route('admin.backup.index')->with('success', $msg);
}
public function saveAutoConfig(Request $request): RedirectResponse
{
$validated = $request->validate([
'backup_auto_enabled' => 'nullable|boolean',
'backup_auto_frequency' => 'nullable|in:daily,weekly,monthly',
'backup_auto_hour' => 'nullable|integer|min:0|max:23',
]);
$setting = AppSetting::first() ?? new AppSetting();
$setting->backup_auto_enabled = !empty($validated['backup_auto_enabled']);
$setting->backup_auto_frequency = $validated['backup_auto_frequency'] ?? 'daily';
$setting->backup_auto_hour = $validated['backup_auto_hour'] ?? 3;
$setting->save();
return redirect()->route('admin.backup.index')
->with('success', 'Configurazione backup automatico salvata.');
}
public function downloadMigrationScript(): JsonResponse
{
$script = view('admin.backup.migration-script')->render();
return response()->json(['script' => $script]);
}
}
@@ -3,20 +3,28 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\EmailAttachment;
use App\Models\EmailFolder;
use App\Models\EmailSetting; use App\Models\EmailSetting;
use App\Models\SenderAccount;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
class EmailSettingsController extends Controller class EmailSettingsController extends Controller
{ {
public function index() public function index()
{ {
$this->authorizeWrite('settings');
$settings = EmailSetting::first() ?? new EmailSetting(); $settings = EmailSetting::first() ?? new EmailSetting();
return view('admin.email-settings.index', compact('settings')); $senderAccounts = SenderAccount::orderBy('email_address')->get();
return view('admin.email-settings.index', compact('settings', 'senderAccounts'));
} }
public function save(Request $request) public function save(Request $request)
{ {
$this->authorizeWrite('settings');
$validated = $request->validate([ $validated = $request->validate([
'imap_host' => 'required|string|max:255', 'imap_host' => 'required|string|max:255',
'imap_port' => 'required|integer|min:1|max:65535', 'imap_port' => 'required|integer|min:1|max:65535',
@@ -26,11 +34,15 @@ class EmailSettingsController extends Controller
'smtp_host' => 'nullable|string|max:255', 'smtp_host' => 'nullable|string|max:255',
'smtp_port' => 'nullable|integer|min:1|max:65535', 'smtp_port' => 'nullable|integer|min:1|max:65535',
'smtp_encryption' => 'nullable|in:ssl,tls,none', 'smtp_encryption' => 'nullable|in:ssl,tls,none',
'smtp_username' => 'nullable|string|max:255',
'smtp_password' => 'nullable|string',
'email_address' => 'required|email', 'email_address' => 'required|email',
'email_name' => 'nullable|string|max:255', 'email_name' => 'nullable|string|max:255',
'reply_to' => 'nullable|email', 'reply_to' => 'nullable|email',
'sync_interval_minutes' => 'nullable|integer|min:1|max:60', 'sync_interval_minutes' => 'nullable|integer|min:1|max:60',
'is_active' => 'boolean', 'is_active' => 'boolean',
'signature' => 'nullable|string',
'signature_enabled' => 'boolean',
]); ]);
if (!empty($validated['imap_password'])) { if (!empty($validated['imap_password'])) {
@@ -54,6 +66,24 @@ class EmailSettingsController extends Controller
if ($verify && if ($verify &&
$verify->imap_host === $validated['imap_host'] && $verify->imap_host === $validated['imap_host'] &&
$verify->email_address === $validated['email_address']) { $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.'); return back()->with('success', 'Impostazioni email salvate e verificate nel database.');
} else { } else {
return back()->with('error', 'Errore nel salvataggio: i dati non sono stati salvati correttamente.'); return back()->with('error', 'Errore nel salvataggio: i dati non sono stati salvati correttamente.');
@@ -62,6 +92,7 @@ class EmailSettingsController extends Controller
public function testConnection(Request $request) public function testConnection(Request $request)
{ {
$this->authorizeWrite('settings');
$settings = EmailSetting::first(); $settings = EmailSetting::first();
if (!$settings) { if (!$settings) {
@@ -87,6 +118,7 @@ class EmailSettingsController extends Controller
public function testSmtp(Request $request) public function testSmtp(Request $request)
{ {
$this->authorizeWrite('settings');
$request->validate([ $request->validate([
'email' => 'required|email', 'email' => 'required|email',
]); ]);
@@ -164,6 +196,7 @@ class EmailSettingsController extends Controller
public function syncNow() public function syncNow()
{ {
$this->authorizeWrite('settings');
$settings = EmailSetting::getActive(); $settings = EmailSetting::getActive();
if (!$settings) { if (!$settings) {
@@ -178,4 +211,119 @@ class EmailSettingsController extends Controller
return back()->with('error', 'Errore sync: ' . $e->getMessage()); return back()->with('error', 'Errore sync: ' . $e->getMessage());
} }
} }
public function senderStore(Request $request)
{
$this->authorizeWrite('settings');
$data = $request->validate([
'email_address' => 'required|email|max:255|unique:sender_accounts,email_address',
'email_name' => 'nullable|string|max:255',
'smtp_host' => 'nullable|string|max:255',
'smtp_port' => 'nullable|integer|min:1|max:65535',
'smtp_encryption' => 'nullable|in:tls,ssl,none',
'smtp_username' => 'nullable|string|max:255',
'smtp_password' => 'nullable|string',
'reply_to' => 'nullable|email|max:255',
'verify_email' => 'nullable|email|max:255',
'note' => 'nullable|string|max:500',
'is_active' => 'boolean',
]);
if (!empty($data['smtp_password'])) {
$data['smtp_password'] = Crypt::encryptString($data['smtp_password']);
} else {
unset($data['smtp_password']);
}
$sender = SenderAccount::create($data);
return back()->with('success', 'Mittente "' . $sender->email_address . '" creato con successo.');
}
public function senderUpdate(Request $request, int $id)
{
$this->authorizeWrite('settings');
$sender = SenderAccount::findOrFail($id);
$data = $request->validate([
'email_address' => 'required|email|max:255|unique:sender_accounts,email_address,' . $id,
'email_name' => 'nullable|string|max:255',
'smtp_host' => 'nullable|string|max:255',
'smtp_port' => 'nullable|integer|min:1|max:65535',
'smtp_encryption' => 'nullable|in:tls,ssl,none',
'smtp_username' => 'nullable|string|max:255',
'smtp_password' => 'nullable|string',
'reply_to' => 'nullable|email|max:255',
'verify_email' => 'nullable|email|max:255',
'note' => 'nullable|string|max:500',
'is_active' => 'boolean',
]);
if (!empty($data['smtp_password'])) {
$data['smtp_password'] = Crypt::encryptString($data['smtp_password']);
} else {
unset($data['smtp_password']);
}
$sender->update($data);
return back()->with('success', 'Mittente "' . $sender->email_address . '" aggiornato.');
}
public function senderDestroy(int $id)
{
$this->authorizeDelete('settings');
$sender = SenderAccount::findOrFail($id);
$sender->delete();
if (request()->expectsJson()) {
return response()->json(['success' => true, 'message' => 'Mittente eliminato.']);
}
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');
$request->validate(['email' => 'required|email']);
$sender = SenderAccount::findOrFail($id);
try {
$sender->sendEmail(
$request->email,
'Test Configurazione SMTP - ' . config('app.name'),
'Test email da ' . config('app.name') . ' - Configurazione SMTP mittente "' . $sender->email_address . '" funziona!'
);
return response()->json(['success' => true, 'message' => 'Email di test inviata a ' . $request->email]);
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => 'Errore invio: ' . $e->getMessage()]);
}
}
} }
+53 -56
View File
@@ -4,11 +4,14 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\EmailSetting;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\Mime\Address;
class AuthController extends Controller class AuthController extends Controller
{ {
@@ -32,11 +35,9 @@ class AuthController extends Controller
]); ]);
} }
if (Auth::attempt($credentials)) { if (Auth::attempt($credentials, $request->has('remember'))) {
$request->session()->regenerate(); $request->session()->regenerate();
ActivityLog::logLogin(); ActivityLog::logLogin();
return redirect()->intended('/dashboard'); return redirect()->intended('/dashboard');
} }
@@ -45,59 +46,6 @@ class AuthController extends Controller
]); ]);
} }
public function showRegisterForm()
{
return view('auth.register');
}
public function register(Request $request)
{
$data = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$isFirstUser = User::count() === 0;
$defaultPermissions = [
'individui' => 1,
'gruppi' => 1,
'eventi' => 1,
'documenti' => 1,
'mailing' => 1,
'viste' => 1,
];
if ($isFirstUser) {
$defaultPermissions = [
'individui' => 2,
'gruppi' => 2,
'eventi' => 2,
'documenti' => 2,
'mailing' => 2,
'viste' => 2,
];
}
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'is_admin' => $isFirstUser,
'status' => User::STATUS_ACTIVE,
'permissions' => $defaultPermissions,
]);
ActivityLog::log('create', 'utenti', 'Registrato nuovo utente: ' . $user->name, [
'user_id' => $user->id,
]);
Auth::login($user);
return redirect('/dashboard');
}
public function logout(Request $request) public function logout(Request $request)
{ {
Auth::logout(); Auth::logout();
@@ -105,4 +53,53 @@ class AuthController extends Controller
$request->session()->regenerateToken(); $request->session()->regenerateToken();
return redirect('/'); return redirect('/');
} }
public function showForgotForm()
{
return view('auth.forgot-password');
}
public function sendResetLink(Request $request)
{
$request->validate(['email' => 'required|email']);
$user = User::where('email', $request->email)->first();
if (!$user) {
return back()->withErrors(['email' => 'Nessun account trovato con questa email.']);
}
$token = Password::broker()->createToken($user);
$user->sendPasswordResetNotification($token);
return back()->with('success', 'Ti abbiamo inviato un link per il reset della password via email.');
}
public function showResetForm(string $token)
{
return view('auth.reset-password', ['token' => $token]);
}
public function reset(Request $request)
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => 'required|string|min:8|confirmed',
]);
$status = Password::broker()->reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) {
$user->forceFill([
'password' => Hash::make($password),
])->save();
}
);
if ($status === Password::PASSWORD_RESET) {
return redirect()->route('login')->with('success', 'Password reimpostata con successo. Accedi con la nuova password.');
}
return back()->withErrors(['email' => [__($status)]]);
}
} }
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class ProfileController extends Controller
{
public function show()
{
return view('auth.profile');
}
public function updatePassword(Request $request)
{
$request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(8)],
]);
$request->user()->update([
'password' => Hash::make($request->password),
]);
return back()->with('success', 'Password aggiornata con successo.');
}
}
@@ -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]);
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\DocumentoCartella;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DocumentoCartellaController extends Controller
{
public function store(Request $request): JsonResponse
{
$this->authorizeWrite('documenti');
$data = $request->validate([
'nome' => 'required|string|max:255',
'parent_id' => 'nullable|integer|exists:documenti_cartelle,id',
]);
$cartella = DocumentoCartella::create([
'nome' => $data['nome'],
'parent_id' => $data['parent_id'] ?? null,
]);
return response()->json([
'success' => true,
'cartella' => $cartella,
]);
}
public function update(Request $request, int $id): JsonResponse
{
$this->authorizeWrite('documenti');
$cartella = DocumentoCartella::findOrFail($id);
$data = $request->validate([
'nome' => 'required|string|max:255',
'parent_id' => 'nullable|integer|exists:documenti_cartelle,id',
]);
$updateData = ['nome' => $data['nome']];
if (array_key_exists('parent_id', $data)) {
if ((int) $data['parent_id'] === $id) {
return response()->json(['success' => false, 'message' => 'Una cartella non può essere spostata dentro se stessa.'], 422);
}
$updateData['parent_id'] = $data['parent_id'];
}
$cartella->update($updateData);
return response()->json(['success' => true]);
}
public function destroy(int $id): JsonResponse
{
$this->authorizeDelete('documenti');
$cartella = DocumentoCartella::findOrFail($id);
$documentCount = $cartella->documenti()->count();
if ($documentCount > 0) {
return response()->json([
'success' => false,
'message' => "Impossibile eliminare: {$documentCount} documento(i) presenti nella cartella. Sposta o elimina prima i documenti.",
], 409);
}
$childrenCount = $cartella->children()->count();
if ($childrenCount > 0) {
return response()->json([
'success' => false,
'message' => "Impossibile eliminare: {$childrenCount} sottocartella(e) presenti. Elimina prima le sottocartelle.",
], 409);
}
$cartella->delete();
return response()->json(['success' => true]);
}
public function breadcrumb(int $id): JsonResponse
{
$cartella = DocumentoCartella::with('parent')->findOrFail($id);
$ancestors = $cartella->ancestors();
$items = [];
$items[] = ['id' => null, 'nome' => 'Tutti i documenti'];
foreach ($ancestors as $anc) {
$items[] = ['id' => $anc->id, 'nome' => $anc->nome];
}
$items[] = ['id' => $cartella->id, 'nome' => $cartella->nome];
return response()->json(['items' => $items]);
}
}
+399 -32
View File
@@ -1,23 +1,80 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\AppSetting;
use App\Models\Documento; use App\Models\Documento;
use App\Models\DocumentoCartella;
use App\Models\Individuo; use App\Models\Individuo;
use App\Models\Gruppo; use App\Models\Gruppo;
use App\Models\Evento; use App\Models\Evento;
use App\Models\StorageRepository;
use App\Models\Tag;
use App\Models\TipologiaDocumento; use App\Models\TipologiaDocumento;
use App\Services\StorageRepositoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class DocumentoController extends Controller class DocumentoController extends Controller
{ {
public function __construct(
private readonly StorageRepositoryService $repoService
) {}
private function resolveStorageDisk(?Documento $documento = null): string
{
if ($documento && $documento->repository_id) {
return 'local';
}
if ($documento && $documento->storage_disk) {
return $documento->storage_disk;
}
return AppSetting::getDocumentiStorageDisk();
}
public function index(Request $request) public function index(Request $request)
{ {
$this->authorizeRead('documenti'); $this->authorizeRead('documenti');
$documenti = Documento::with(['user', 'target'])
->orderByDesc('created_at') $folderId = $request->get('folder_id');
->paginate(20); $repoId = $request->get('repository_id');
$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) {
$query->where('cartella_id', $folderId);
} else {
$query->whereNull('repository_id');
}
$documenti = $query->paginate(20);
$allTags = Tag::orderBy('name')->get();
$cartelle = DocumentoCartella::with('children')
->whereNull('parent_id')
->orderBy('nome')
->get();
$currentFolder = $folderId ? DocumentoCartella::find($folderId) : null;
$breadcrumb = $currentFolder ? $currentFolder->ancestors() : [];
$sottoCartelle = $folderId
? DocumentoCartella::where('parent_id', $currentFolder->id)->orderBy('nome')->get()
: DocumentoCartella::whereNull('parent_id')->orderBy('nome')->get();
$currentRepo = $repoId ? StorageRepository::find($repoId) : null;
$repositories = StorageRepository::attivi()->get();
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$gruppi = Gruppo::orderBy('nome')->get(); $gruppi = Gruppo::orderBy('nome')->get();
@@ -25,7 +82,56 @@ class DocumentoController extends Controller
$mailingLists = \App\Models\MailingList::orderBy('nome')->get(); $mailingLists = \App\Models\MailingList::orderBy('nome')->get();
$tipologie = TipologiaDocumento::attive(); $tipologie = TipologiaDocumento::attive();
return view('documenti.index', compact('documenti', 'individui', 'gruppi', 'eventi', 'mailingLists', 'tipologie')); $cartelleMoveOptions = DocumentoCartella::whereNull('parent_id')
->with('children')
->orderBy('nome')
->get()
->map(fn($f) => [
'id' => $f->id,
'nome' => $f->nome,
'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', 'allTags',
'vista', 'visibleColumns', 'tableColumns', 'entityType', 'columnWidths',
'userVistas'
));
} }
public function create() public function create()
@@ -37,16 +143,19 @@ class DocumentoController extends Controller
$tipologie = TipologiaDocumento::attive(); $tipologie = TipologiaDocumento::attive();
return view('documenti.create', compact('individui', 'gruppi', 'eventi', 'tipologie')); return view('documenti.create', compact('individui', 'gruppi', 'eventi', 'tipologie'));
} }
public function edit($documento) public function edit($documento)
{ {
$this->authorizeWrite('documenti'); $this->authorizeWrite('documenti');
$documento = Documento::with('target')->findOrFail($documento); $documento = Documento::with(['target', 'tags'])->findOrFail($documento);
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$gruppi = Gruppo::orderBy('nome')->get(); $gruppi = Gruppo::orderBy('nome')->get();
$eventi = Evento::orderBy('nome_evento')->get(); $eventi = Evento::orderBy('nome_evento')->get();
$mailingLists = \App\Models\MailingList::orderBy('nome')->get(); $mailingLists = \App\Models\MailingList::orderBy('nome')->get();
$tipologie = TipologiaDocumento::attive(); $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) public function options(Request $request)
@@ -99,6 +208,8 @@ class DocumentoController extends Controller
'visibilita_target_id' => 'nullable|integer', 'visibilita_target_id' => 'nullable|integer',
'visibilita_target_type' => 'nullable|string', 'visibilita_target_type' => 'nullable|string',
'note' => 'nullable|string', 'note' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$data['visibilita'] = $request->contesto_tipo ?: 'pubblico'; $data['visibilita'] = $request->contesto_tipo ?: 'pubblico';
@@ -119,6 +230,12 @@ class DocumentoController extends Controller
$documento->update($data); $documento->update($data);
if ($request->has('tags')) {
$documento->tags()->sync($request->tags);
} else {
$documento->tags()->detach();
}
return redirect('/documenti')->with('success', 'Documento aggiornato.'); return redirect('/documenti')->with('success', 'Documento aggiornato.');
} }
@@ -132,10 +249,33 @@ class DocumentoController extends Controller
'visibilita' => 'required|in:pubblico,individuo,gruppo', 'visibilita' => 'required|in:pubblico,individuo,gruppo',
'visibilita_target_id' => 'nullable|integer', 'visibilita_target_id' => 'nullable|integer',
'visibilita_target_type' => 'nullable|string', 'visibilita_target_type' => 'nullable|string',
'cartella_id' => 'nullable|integer|exists:documenti_cartelle,id',
'repository_id' => 'nullable|integer|exists:storage_repositories,id',
]); ]);
$file = $request->file('file'); $repositoryId = $data['repository_id'] ?? null;
$path = $file->store('documenti', 'local');
if ($repositoryId) {
$repo = StorageRepository::findOrFail($repositoryId);
$filesystem = $this->repoService->buildFilesystem($repo);
if (!$filesystem) {
return back()->with('error', 'Impossibile connettersi al repository remoto.');
}
$extension = $request->file('file')->getClientOriginalExtension();
$filename = $data['nome_file'] . '.' . ($extension ?: $request->file('file')->guessExtension());
$path = 'documents/' . date('Y/m/d') . '/' . $filename;
$stream = fopen($request->file('file')->getRealPath(), 'rb');
$filesystem->writeStream($path, $stream);
fclose($stream);
$filePath = $path;
} else {
$disk = $this->resolveStorageDisk();
$storagePath = AppSetting::getDocumentiStoragePath();
$filePath = $request->file('file')->store($storagePath, $disk);
}
$visibilitaTargetType = null; $visibilitaTargetType = null;
if ($data['visibilita'] === 'individuo' && $data['visibilita_target_id']) { if ($data['visibilita'] === 'individuo' && $data['visibilita_target_id']) {
@@ -146,15 +286,18 @@ class DocumentoController extends Controller
Documento::create([ Documento::create([
'nome_file' => $data['nome_file'], 'nome_file' => $data['nome_file'],
'file_path' => $path, 'file_path' => $filePath,
'storage_disk' => $disk ?? null,
'tipo' => 'upload', 'tipo' => 'upload',
'tipologia' => $data['tipologia'], 'tipologia' => $data['tipologia'],
'visibilita' => $data['visibilita'], 'visibilita' => $data['visibilita'],
'visibilita_target_id' => $data['visibilita_target_id'] ?? null, 'visibilita_target_id' => $data['visibilita_target_id'] ?? null,
'visibilita_target_type' => $visibilitaTargetType, 'visibilita_target_type' => $visibilitaTargetType,
'mime_type' => $file->getMimeType(), 'mime_type' => $request->file('file')->getMimeType(),
'dimensione' => $file->getSize(), 'dimensione' => $request->file('file')->getSize(),
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'cartella_id' => $data['cartella_id'] ?? null,
'repository_id' => $repositoryId,
]); ]);
$redirect = $request->_redirect ?? url()->previous(); $redirect = $request->_redirect ?? url()->previous();
@@ -166,14 +309,40 @@ class DocumentoController extends Controller
$this->authorizeRead('documenti'); $this->authorizeRead('documenti');
$documento = Documento::findOrFail($documento); $documento = Documento::findOrFail($documento);
if (!Storage::disk('local')->exists($documento->file_path)) {
abort(404);
}
$extension = pathinfo($documento->file_path, PATHINFO_EXTENSION); $extension = pathinfo($documento->file_path, PATHINFO_EXTENSION);
$filename = $extension ? $documento->nome_file . '.' . $extension : $documento->nome_file; $filename = $extension ? $documento->nome_file . '.' . $extension : $documento->nome_file;
return Storage::disk('local')->download( if ($documento->repository_id) {
$repo = StorageRepository::find($documento->repository_id);
if (!$repo) {
abort(404);
}
$filesystem = $this->repoService->buildFilesystem($repo);
if (!$filesystem || !$filesystem->fileExists($documento->file_path)) {
abort(404);
}
$stream = $filesystem->readStream($documento->file_path);
if (!$stream) {
abort(404);
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
fclose($stream);
}, 200, [
'Content-Type' => $documento->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => $documento->dimensione,
]);
}
$disk = $this->resolveStorageDisk($documento);
if (!Storage::disk($disk)->exists($documento->file_path)) {
abort(404);
}
return Storage::disk($disk)->download(
$documento->file_path, $documento->file_path,
$filename, $filename,
['Content-Type' => $documento->mime_type] ['Content-Type' => $documento->mime_type]
@@ -185,40 +354,74 @@ class DocumentoController extends Controller
$this->authorizeRead('documenti'); $this->authorizeRead('documenti');
$documento = Documento::findOrFail($documento); $documento = Documento::findOrFail($documento);
if (!Storage::disk('local')->exists($documento->file_path)) {
abort(404);
}
$fullPath = Storage::disk('local')->path($documento->file_path);
$mimeType = $documento->mime_type;
$previewableMimeTypes = [ $previewableMimeTypes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
'application/pdf', 'application/pdf',
'text/plain', 'text/html', 'text/csv', 'text/plain', 'text/html', 'text/csv',
]; ];
if (in_array($mimeType, $previewableMimeTypes)) { if (!in_array($documento->mime_type, $previewableMimeTypes)) {
return response()->file($fullPath, ['Content-Type' => $mimeType]);
}
return response()->view('documenti.preview-fallback', [ return response()->view('documenti.preview-fallback', [
'documento' => $documento, 'documento' => $documento,
])->header('X-Preview-Fallback', 'true'); ])->header('X-Preview-Fallback', 'true');
} }
if ($documento->repository_id) {
$repo = StorageRepository::find($documento->repository_id);
if (!$repo) {
abort(404);
}
$filesystem = $this->repoService->buildFilesystem($repo);
if (!$filesystem || !$filesystem->fileExists($documento->file_path)) {
abort(404);
}
$stream = $filesystem->readStream($documento->file_path);
if (!$stream) {
abort(404);
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
fclose($stream);
}, 200, [
'Content-Type' => $documento->mime_type,
'Content-Disposition' => 'inline; filename="' . basename($documento->file_path) . '"',
]);
}
$disk = $this->resolveStorageDisk($documento);
if (!Storage::disk($disk)->exists($documento->file_path)) {
abort(404);
}
$fullPath = Storage::disk($disk)->path($documento->file_path);
return response()->file($fullPath, ['Content-Type' => $documento->mime_type]);
}
public function destroy($documento) public function destroy($documento)
{ {
$this->authorizeDelete('documenti'); $this->authorizeDelete('documenti');
$documento = Documento::findOrFail($documento); $documento = Documento::findOrFail($documento);
if (Storage::disk('local')->exists($documento->file_path)) { if ($documento->repository_id) {
Storage::disk('local')->delete($documento->file_path); $repo = StorageRepository::find($documento->repository_id);
if ($repo) {
$filesystem = $this->repoService->buildFilesystem($repo);
if ($filesystem && $filesystem->fileExists($documento->file_path)) {
$filesystem->delete($documento->file_path);
}
}
} else {
$disk = $this->resolveStorageDisk($documento);
if (Storage::disk($disk)->exists($documento->file_path)) {
Storage::disk($disk)->delete($documento->file_path);
}
} }
$documento->delete(); $documento->delete();
$redirect = request('_redirect', url('/')); $redirect = request('_redirect', url()->previous());
return redirect($redirect)->with('success', 'Documento eliminato.'); return redirect($redirect)->with('success', 'Documento eliminato.');
} }
@@ -364,6 +567,70 @@ class DocumentoController extends Controller
]); ]);
} }
public function massDownload(Request $request)
{
$this->authorizeRead('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.');
}
$documenti = Documento::whereIn('id', $ids)->get();
if ($documenti->isEmpty()) {
return back()->with('error', 'Nessun documento trovato.');
}
$zip = new \ZipArchive();
$zipPath = tempnam(sys_get_temp_dir(), 'docs_') . '.zip';
if ($zip->open($zipPath, \ZipArchive::CREATE) !== true) {
return back()->with('error', 'Impossibile creare l\'archivio ZIP.');
}
$addedCount = 0;
foreach ($documenti as $documento) {
$extension = pathinfo($documento->file_path, PATHINFO_EXTENSION);
$filename = $extension ? $documento->nome_file . '.' . $extension : $documento->nome_file;
try {
if ($documento->repository_id) {
$repo = StorageRepository::find($documento->repository_id);
if ($repo) {
$filesystem = $this->repoService->buildFilesystem($repo);
if ($filesystem && $filesystem->fileExists($documento->file_path)) {
$stream = $filesystem->readStream($documento->file_path);
if ($stream) {
$zip->addFromString($filename, stream_get_contents($stream));
fclose($stream);
$addedCount++;
}
}
}
} else {
$disk = $this->resolveStorageDisk($documento);
if (Storage::disk($disk)->exists($documento->file_path)) {
$zip->addFile(Storage::disk($disk)->path($documento->file_path), $filename);
$addedCount++;
}
}
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::warning('ZIP download skipped file: ' . $documento->id . ' - ' . $e->getMessage());
}
}
$zip->close();
if ($addedCount === 0) {
unlink($zipPath);
return back()->with('error', 'Nessun file disponibile per il download.');
}
return response()->download($zipPath, 'documenti.zip')->deleteFileAfterSend(true);
}
public function massDestroy(Request $request) public function massDestroy(Request $request)
{ {
$this->authorizeDelete('documenti'); $this->authorizeDelete('documenti');
@@ -377,8 +644,19 @@ class DocumentoController extends Controller
$documenti = Documento::whereIn('id', $ids)->get(); $documenti = Documento::whereIn('id', $ids)->get();
foreach ($documenti as $documento) { foreach ($documenti as $documento) {
if (Storage::disk('local')->exists($documento->file_path)) { if ($documento->repository_id) {
Storage::disk('local')->delete($documento->file_path); $repo = StorageRepository::find($documento->repository_id);
if ($repo) {
$filesystem = $this->repoService->buildFilesystem($repo);
if ($filesystem && $filesystem->fileExists($documento->file_path)) {
$filesystem->delete($documento->file_path);
}
}
} else {
$disk = $this->resolveStorageDisk($documento);
if (Storage::disk($disk)->exists($documento->file_path)) {
Storage::disk($disk)->delete($documento->file_path);
}
} }
$documento->delete(); $documento->delete();
} }
@@ -399,6 +677,8 @@ class DocumentoController extends Controller
$data = $request->validate([ $data = $request->validate([
'tipologia' => 'nullable|string', 'tipologia' => 'nullable|string',
'visibilita' => 'nullable|string', 'visibilita' => 'nullable|string',
'visibilita_target_id' => 'nullable|integer',
'visibilita_target_type' => 'nullable|string',
]); ]);
$tipologia = $data['tipologia'] ?? null; $tipologia = $data['tipologia'] ?? null;
@@ -425,6 +705,22 @@ class DocumentoController extends Controller
} }
} }
if (!empty($data['visibilita_target_id']) && !empty($data['visibilita_target_type'])) {
$typeMap = [
'individuo' => Individuo::class,
'gruppo' => Gruppo::class,
'evento' => Evento::class,
'mailing' => \App\Models\MailingList::class,
];
$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[$targetType];
}
Documento::whereIn('id', $ids)->update($updateData); Documento::whereIn('id', $ids)->update($updateData);
return back()->with('success', count($ids) . ' documenti aggiornati.'); return back()->with('success', count($ids) . ' documenti aggiornati.');
@@ -460,4 +756,75 @@ class DocumentoController extends Controller
return back()->with('success', count($ids) . ' documenti associati.'); 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');
$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([
'cartella_id' => 'nullable|integer|exists:documenti_cartelle,id',
]);
Documento::whereIn('id', $ids)->update([
'cartella_id' => $data['cartella_id'] ?? null,
]);
return back()->with('success', count($ids) . ' documenti spostati.');
}
public function moveDocumento(Request $request, int $id): JsonResponse
{
$this->authorizeWrite('documenti');
$documento = Documento::findOrFail($id);
$data = $request->validate([
'cartella_id' => 'nullable|integer|exists:documenti_cartelle,id',
]);
$documento->update(['cartella_id' => $data['cartella_id'] ?? null]);
return response()->json(['success' => true]);
}
} }
+125 -23
View File
@@ -6,6 +6,7 @@ use App\Models\EmailFolder;
use App\Models\EmailMessage; use App\Models\EmailMessage;
use App\Models\EmailAttachment; use App\Models\EmailAttachment;
use App\Models\EmailSetting; use App\Models\EmailSetting;
use App\Models\SenderAccount;
use App\Models\MailingList; use App\Models\MailingList;
use App\Models\Gruppo; use App\Models\Gruppo;
use App\Models\Individuo; use App\Models\Individuo;
@@ -58,7 +59,8 @@ class EmailController extends Controller
$query->orderBy($sortField === 'sent_at' ? 'received_at' : $sortField, $sortDir); $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(); $folders = EmailFolder::orderBy('sort_order')->get();
$mailingLists = MailingList::where('attiva', true)->get(); $mailingLists = MailingList::where('attiva', true)->get();
$gruppi = Gruppo::orderBy('nome')->get(); $gruppi = Gruppo::orderBy('nome')->get();
@@ -90,6 +92,9 @@ class EmailController extends Controller
$mailingLists = MailingList::where('attiva', true)->get(); $mailingLists = MailingList::where('attiva', true)->get();
$gruppi = Gruppo::orderBy('nome')->get(); $gruppi = Gruppo::orderBy('nome')->get();
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$senderAccounts = SenderAccount::active()->get();
$documenti = Documento::orderBy('nome_file')->get();
$prefill = []; $prefill = [];
if ($request->reply_to) { if ($request->reply_to) {
@@ -108,7 +113,7 @@ class EmailController extends Controller
} }
} }
return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'prefill')); return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'documenti', 'prefill', 'senderAccounts'));
} }
public function send(Request $request) public function send(Request $request)
@@ -125,11 +130,6 @@ class EmailController extends Controller
$validated = $request->validate($rules); $validated = $request->validate($rules);
$settings = EmailSetting::getActive();
if (!$settings) {
return back()->with('error', 'Account email non configurato.');
}
$recipients = $this->resolveRecipients($request); $recipients = $this->resolveRecipients($request);
\Illuminate\Support\Facades\Log::info('Email send attempt', [ \Illuminate\Support\Facades\Log::info('Email send attempt', [
@@ -137,44 +137,94 @@ class EmailController extends Controller
'recipients_count' => count($recipients), 'recipients_count' => count($recipients),
'subject' => $validated['subject'], 'subject' => $validated['subject'],
'destinatario_tipo' => $request->destinatario_tipo, 'destinatario_tipo' => $request->destinatario_tipo,
'to' => $validated['to'] ?? null, 'mittente_id' => $request->mittente_id,
'individui' => $request->individui,
'gruppo_id' => $request->gruppo_id,
'mailing_list_id' => $request->mailing_list_id,
]); ]);
if (empty($recipients)) { if (empty($recipients)) {
return back()->with('error', 'Nessun destinatario trovato. Seleziona almeno un destinatario dal menu a tendina o inserisci un indirizzo email manuale.')->withInput(); return back()->with('error', 'Nessun destinatario trovato. Seleziona almeno un destinatario dal menu a tendina o inserisci un indirizzo email manuale.')->withInput();
} }
$useSender = $request->filled('mittente_id') && $request->mittente_id > 0;
if ($useSender) {
return $this->sendWithSender($request, $validated, $recipients);
}
$settings = EmailSetting::getActive();
if (!$settings) {
return back()->with('error', 'Account email non configurato.');
}
$imapSuccess = false; $imapSuccess = false;
$imapError = null; $imapError = null;
$attachments = $this->processAttachments($request);
foreach ($recipients as $recipient) { foreach ($recipients as $recipient) {
try { try {
$this->sendViaImap($settings, $recipient, $validated['subject'], $validated['body'], $request->cc); $bodyWithSig = $this->appendSignature($validated['body'], $settings->getSignature(), $settings->signature_enabled ?? false);
$this->sendViaImap($settings, $recipient, $validated['subject'], $bodyWithSig, $request->cc, $attachments);
$imapSuccess = true; $imapSuccess = true;
} catch (\Exception $e) { } catch (\Exception $e) {
$imapError = $e->getMessage(); $imapError = $e->getMessage();
\Illuminate\Support\Facades\Log::error('IMAP send error', ['error' => $e->getMessage()]); \Illuminate\Support\Facades\Log::error('SMTP send error', ['error' => $e->getMessage()]);
} }
} }
try { try {
$this->storeSentMessage($settings, $recipients, $validated['subject'], $validated['body'], $request->cc); $bodyWithSig = $this->appendSignature($validated['body'], $settings->getSignature(), $settings->signature_enabled ?? false);
\Illuminate\Support\Facades\Log::info('Stored sent message'); $this->storeSentMessage($settings, $recipients, $validated['subject'], $bodyWithSig, $request->cc);
} catch (\Exception $e) { } catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Store sent message error', ['error' => $e->getMessage()]); \Illuminate\Support\Facades\Log::error('Store sent message error', ['error' => $e->getMessage()]);
return back()->with('error', 'Errore nel salvataggio: ' . $e->getMessage())->withInput(); return back()->with('error', 'Errore nel salvataggio: ' . $e->getMessage())->withInput();
} }
if (!$imapSuccess && $imapError) { if (!$imapSuccess && $imapError) {
return redirect()->route('email.index', 'sent')->with('warning', 'Email salvata in Invio ma non inviata via IMAP: ' . $imapError); return redirect()->route('email.index', 'sent')->with('warning', 'Email salvata in Invio ma non inviata via SMTP: ' . $imapError);
} }
return redirect()->route('email.index', 'sent')->with('success', 'Email inviata con successo.'); return redirect()->route('email.index', 'sent')->with('success', 'Email inviata con successo.');
} }
private function sendWithSender(Request $request, array $validated, array $recipients)
{
$sender = SenderAccount::findOrFail($request->mittente_id);
$attachmentPaths = $this->resolveAttachmentPaths($request);
$ok = 0;
$errors = [];
foreach ($recipients as $recipient) {
try {
$sender->sendEmail($recipient, $validated['subject'], $validated['body'], $attachmentPaths);
$ok++;
} catch (\Exception $e) {
$errors[] = $recipient . ': ' . $e->getMessage();
}
}
if ($sender->verify_email) {
$reportSubject = '✅ Invio: ' . $validated['subject'] . ' - ' . $sender->email_address;
$reportBody = "Report invio mittente: {$sender->email_address}\n"
. "Destinatari totali: " . count($recipients) . "\n"
. "Inviate con successo: {$ok}\n"
. "Fallite: " . count($errors) . "\n";
if (!empty($errors)) {
$reportBody .= "\nDettaglio errori:\n" . implode("\n", $errors);
}
$sender->sendReport($reportSubject, $reportBody);
}
if ($ok === 0 && !empty($errors)) {
return redirect()->route('email.index', 'sent')->with('error', 'Invio fallito: ' . implode('; ', $errors));
}
$message = $ok . ' email inviate con successo tramite "' . $sender->email_address . '".';
if (!empty($errors)) {
$message .= ' ' . count($errors) . ' fallite.';
}
return redirect()->route('email.index', 'sent')->with('success', $message);
}
public function storeDraft(Request $request) public function storeDraft(Request $request)
{ {
$this->authorizeWrite('email'); $this->authorizeWrite('email');
@@ -293,7 +343,7 @@ class EmailController extends Controller
$message->delete(); $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) public function downloadAttachment($id)
@@ -454,10 +504,8 @@ class EmailController extends Controller
return $body . "\n\n---\n" . $signature; return $body . "\n\n---\n" . $signature;
} }
private function sendViaImap($settings, $to, $subject, $body, $cc = null) private function sendViaImap($settings, $to, $subject, $body, $cc = null, array $attachmentPaths = [])
{ {
$bodyWithSignature = $this->appendSignature($body, $settings->getSignature(), $settings->signature_enabled ?? false);
\Illuminate\Support\Facades\Log::info('sendViaSMTP start', ['to' => $to, 'subject' => $subject]); \Illuminate\Support\Facades\Log::info('sendViaSMTP start', ['to' => $to, 'subject' => $subject]);
$smtpPassword = $settings->getDecryptedSmtpPassword() ?? $settings->getDecryptedPassword(); $smtpPassword = $settings->getDecryptedSmtpPassword() ?? $settings->getDecryptedPassword();
@@ -475,8 +523,6 @@ class EmailController extends Controller
$encryption $encryption
); );
\Illuminate\Support\Facades\Log::info('SMTP DSN', ['dsn' => $dsn]);
try { try {
$transport = \Symfony\Component\Mailer\Transport::fromDsn($dsn); $transport = \Symfony\Component\Mailer\Transport::fromDsn($dsn);
$mailer = new \Symfony\Component\Mailer\Mailer($transport); $mailer = new \Symfony\Component\Mailer\Mailer($transport);
@@ -498,12 +544,16 @@ class EmailController extends Controller
->from($fromAddress) ->from($fromAddress)
->to($to) ->to($to)
->subject($subject) ->subject($subject)
->text($bodyWithSignature); ->text($body);
if ($cc) { if ($cc) {
$email->cc($cc); $email->cc($cc);
} }
foreach ($attachmentPaths as $attachmentPath) {
$email->attachFromPath($attachmentPath);
}
$mailer->send($email); $mailer->send($email);
\Illuminate\Support\Facades\Log::info('Email sent via SMTP successfully'); \Illuminate\Support\Facades\Log::info('Email sent via SMTP successfully');
@@ -514,6 +564,58 @@ class EmailController extends Controller
} }
} }
private function processAttachments(Request $request): array
{
$paths = [];
if ($request->hasFile('allegati')) {
foreach ($request->file('allegati') as $file) {
if ($file->isValid()) {
$paths[] = $file->getRealPath();
}
}
}
$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;
}
private function resolveAttachmentPaths(Request $request): array
{
$paths = [];
if ($request->hasFile('allegati')) {
foreach ($request->file('allegati') as $file) {
if ($file->isValid()) {
$paths[] = $file->getRealPath();
}
}
}
$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 && \Illuminate\Support\Facades\Storage::exists($doc->file_path)) {
$paths[] = \Illuminate\Support\Facades\Storage::path($doc->file_path);
}
}
}
return $paths;
}
private function storeSentMessage($settings, $recipients, $subject, $body, $cc = null) private function storeSentMessage($settings, $recipients, $subject, $body, $cc = null)
{ {
$sentFolder = EmailFolder::where('type', 'sent')->first(); $sentFolder = EmailFolder::where('type', 'sent')->first();
+211 -7
View File
@@ -5,15 +5,24 @@ namespace App\Http\Controllers;
use App\Models\Evento; use App\Models\Evento;
use App\Models\Gruppo; use App\Models\Gruppo;
use App\Models\Individuo; use App\Models\Individuo;
use App\Models\Tag;
use App\Models\TipologiaEvento; use App\Models\TipologiaEvento;
use App\Services\IcsExportService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\StreamedResponse;
class EventoController extends Controller class EventoController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$this->authorizeRead('eventi'); $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')) { if ($request->filled('search')) {
$query->where('nome_evento', 'like', '%' . $request->search . '%'); $query->where('nome_evento', 'like', '%' . $request->search . '%');
@@ -41,10 +50,47 @@ class EventoController extends Controller
$query->orderByDesc('created_at'); $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(); $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) public function create(Request $request)
@@ -54,8 +100,9 @@ class EventoController extends Controller
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$selectedGruppo = $request->query('gruppo_id') ? Gruppo::find($request->query('gruppo_id')) : null; $selectedGruppo = $request->query('gruppo_id') ? Gruppo::find($request->query('gruppo_id')) : null;
$tipologieEventi = TipologiaEvento::attive(); $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) public function store(Request $request)
@@ -84,6 +131,8 @@ class EventoController extends Controller
'responsabili.*' => 'exists:individui,id', 'responsabili.*' => 'exists:individui,id',
'luogo_indirizzo' => 'nullable|string|max:500', 'luogo_indirizzo' => 'nullable|string|max:500',
'luogo_url_maps' => 'nullable|string', 'luogo_url_maps' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$evento = Evento::create([ $evento = Evento::create([
@@ -106,6 +155,10 @@ class EventoController extends Controller
'luogo_url_maps' => $data['luogo_url_maps'] ?? null, 'luogo_url_maps' => $data['luogo_url_maps'] ?? null,
]); ]);
if (!empty($data['tags'])) {
$evento->tags()->attach($data['tags']);
}
if (!empty($data['gruppi'])) { if (!empty($data['gruppi'])) {
$evento->gruppi()->attach($data['gruppi']); $evento->gruppi()->attach($data['gruppi']);
} }
@@ -120,19 +173,21 @@ class EventoController extends Controller
public function show($evento) public function show($evento)
{ {
$this->authorizeRead('eventi'); $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')); return view('eventi.show', compact('evento'));
} }
public function edit($evento) public function edit($evento)
{ {
$this->authorizeWrite('eventi'); $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(); $gruppi = Gruppo::orderBy('nome')->get();
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$tipologieEventi = TipologiaEvento::attive(); $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) public function update(Request $request, $evento)
@@ -163,6 +218,8 @@ class EventoController extends Controller
'responsabili.*' => 'exists:individui,id', 'responsabili.*' => 'exists:individui,id',
'luogo_indirizzo' => 'nullable|string|max:500', 'luogo_indirizzo' => 'nullable|string|max:500',
'luogo_url_maps' => 'nullable|string', 'luogo_url_maps' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$evento->update([ $evento->update([
@@ -185,6 +242,7 @@ class EventoController extends Controller
'luogo_url_maps' => $data['luogo_url_maps'] ?? null, 'luogo_url_maps' => $data['luogo_url_maps'] ?? null,
]); ]);
$evento->tags()->sync($data['tags'] ?? []);
$evento->gruppi()->sync($data['gruppi'] ?? []); $evento->gruppi()->sync($data['gruppi'] ?? []);
$evento->responsabili()->sync($data['responsabili'] ?? []); $evento->responsabili()->sync($data['responsabili'] ?? []);
@@ -219,6 +277,55 @@ class EventoController extends Controller
return redirect('/eventi')->with('success', count($eventi) . ' eventi eliminati con successo.'); return redirect('/eventi')->with('success', count($eventi) . ' eventi eliminati con successo.');
} }
public function exportIcs(int $id, IcsExportService $ics): StreamedResponse
{
$this->authorizeRead('eventi');
$evento = Evento::findOrFail($id);
$content = $ics->generateSingle($evento);
return response()->streamDownload(function () use ($content) {
echo $content;
}, $ics->getFilename($evento), [
'Content-Type' => 'text/calendar; charset=utf-8',
'Content-Length' => strlen($content),
]);
}
public function exportSelectedIcs(Request $request, IcsExportService $ics): StreamedResponse
{
$this->authorizeRead('eventi');
$ids = $request->input('ids', []);
if (!empty($ids) && is_array($ids)) {
$eventi = Evento::whereIn('id', $ids)->get();
} else {
$query = Evento::query();
if ($request->filled('search')) {
$query->where('nome_evento', 'like', '%' . $request->search . '%');
}
if ($request->filled('gruppo_id')) {
$query->whereHas('gruppi', fn($q) => $q->where('gruppi.id', $request->gruppo_id));
}
if ($request->filled('tipo')) {
$query->where('tipo_recorrenza', $request->tipo);
}
$eventi = $query->get();
}
if ($eventi->isEmpty()) {
return redirect('/eventi')->with('error', 'Nessun evento da esportare.');
}
$content = $ics->generateCollection($eventi);
return response()->streamDownload(function () use ($content) {
echo $content;
}, $ics->getCollectionFilename(), [
'Content-Type' => 'text/calendar; charset=utf-8',
'Content-Length' => strlen($content),
]);
}
public function calendar() public function calendar()
{ {
$this->authorizeRead('eventi'); $this->authorizeRead('eventi');
@@ -426,4 +533,101 @@ class EventoController extends Controller
return $colors['primary']; 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\Gruppo;
use App\Models\Individuo; use App\Models\Individuo;
use App\Models\Diocesi; use App\Models\Diocesi;
use App\Models\Tag;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Log;
class GruppoController extends Controller class GruppoController extends Controller
{ {
@@ -16,6 +20,7 @@ class GruppoController extends Controller
$this->authorizeRead('gruppi'); $this->authorizeRead('gruppi');
$viewMode = request('view', 'table'); $viewMode = request('view', 'table');
$perPage = in_array((int) request('perPage'), [10, 20, 25, 50, 100]) ? (int) request('perPage') : 20;
$user = auth()->user(); $user = auth()->user();
$vista = \App\Models\VistaReport::where('user_id', $user->id) $vista = \App\Models\VistaReport::where('user_id', $user->id)
@@ -23,8 +28,28 @@ class GruppoController extends Controller
->where('is_default', true) ->where('is_default', true)
->first(); ->first();
$allColumns = ['nome', 'descrizione', 'diocesi', 'livello', 'padre', 'membri', 'responsabili', 'indirizzo', 'citta', 'telefono', 'email']; $userVistas = \App\Models\VistaReport::where('user_id', $user->id)
$defaultVisible = ['nome', 'livello', 'padre', 'membri', 'responsabili']; ->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)) { if ($vista && !empty($vista->colonne_visibili)) {
$visibleColumns = $vista->colonne_visibili; $visibleColumns = $vista->colonne_visibili;
@@ -32,9 +57,22 @@ class GruppoController extends Controller
$visibleColumns = $defaultVisible; $visibleColumns = $defaultVisible;
} }
$allGruppi = Gruppo::with(['diocesi', 'parent', 'children', 'individui', 'avatar']) $tableColumns = array_map(fn($col) => [
->orderBy('nome') 'key' => $col['key'],
->get() '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) { ->map(function ($gruppo) {
$depth = 0; $depth = 0;
$parent = $gruppo->parent; $parent = $gruppo->parent;
@@ -47,17 +85,33 @@ class GruppoController extends Controller
}); });
if ($viewMode === 'table') { 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 { } else {
$gruppi = $allGruppi; $gruppi = $allGruppi;
} }
$allTags = Tag::orderBy('name')->get();
return view('gruppi.index', compact( return view('gruppi.index', compact(
'gruppi', 'gruppi',
'allColumns', 'allColumns',
'visibleColumns', 'visibleColumns',
'viewMode', 'viewMode',
'vista' 'vista',
'allTags',
'tableColumns',
'entityType',
'columnWidths',
'userVistas'
)); ));
} }
@@ -91,7 +145,8 @@ class GruppoController extends Controller
$allGruppi = Gruppo::orderBy('nome')->get(); $allGruppi = Gruppo::orderBy('nome')->get();
$selectedParent = $request->query('parent_id') ? Gruppo::find($request->query('parent_id')) : null; $selectedParent = $request->query('parent_id') ? Gruppo::find($request->query('parent_id')) : null;
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); $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) public function store(Request $request)
@@ -109,6 +164,8 @@ class GruppoController extends Controller
'città_incontro' => 'nullable|string|max:255', 'città_incontro' => 'nullable|string|max:255',
'sigla_provincia_incontro' => 'nullable|string|max:2', 'sigla_provincia_incontro' => 'nullable|string|max:2',
'mappa_posizione' => 'nullable|string', 'mappa_posizione' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
if (!empty($data['responsabile_ids'])) { if (!empty($data['responsabile_ids'])) {
@@ -117,6 +174,10 @@ class GruppoController extends Controller
$gruppo = Gruppo::create($data); $gruppo = Gruppo::create($data);
if ($request->has('tags')) {
$gruppo->tags()->sync($request->tags);
}
if ($request->has('individui')) { if ($request->has('individui')) {
foreach ($request->individui as $individuoData) { foreach ($request->individui as $individuoData) {
if (!empty($individuoData['individuo_id'])) { if (!empty($individuoData['individuo_id'])) {
@@ -136,7 +197,7 @@ class GruppoController extends Controller
$this->authorizeRead('gruppi'); $this->authorizeRead('gruppi');
$gruppo = Gruppo::with(['parent', 'children', 'diocesi', 'individui.contatti', 'individui.documenti', 'individui' => function ($q) { $gruppo = Gruppo::with(['parent', 'children', 'diocesi', 'individui.contatti', 'individui.documenti', 'individui' => function ($q) {
$q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione'); $q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione');
}, 'avatar', 'eventi' => function ($q) { }, 'avatar', 'tags', 'eventi' => function ($q) {
$q->where('is_incontro_gruppo', true); $q->where('is_incontro_gruppo', true);
}, 'eventi.responsabili.contatti'])->findOrFail($id); }, 'eventi.responsabili.contatti'])->findOrFail($id);
return view('gruppi.show', compact('gruppo')); return view('gruppi.show', compact('gruppo'));
@@ -145,11 +206,13 @@ class GruppoController extends Controller
public function edit($id) public function edit($id)
{ {
$this->authorizeWrite('gruppi'); $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(); $diocesi = Diocesi::orderBy('nome')->get();
$gruppi = Gruppo::where('id', '!=', $gruppo->id)->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(); $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) public function update(Request $request, $id)
@@ -168,6 +231,8 @@ class GruppoController extends Controller
'città_incontro' => 'nullable|string|max:255', 'città_incontro' => 'nullable|string|max:255',
'sigla_provincia_incontro' => 'nullable|string|max:2', 'sigla_provincia_incontro' => 'nullable|string|max:2',
'mappa_posizione' => 'nullable|string', 'mappa_posizione' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
if (!empty($data['responsabile_ids'])) { if (!empty($data['responsabile_ids'])) {
@@ -178,6 +243,12 @@ class GruppoController extends Controller
$gruppo->update($data); $gruppo->update($data);
if ($request->has('tags')) {
$gruppo->tags()->sync($request->tags);
} else {
$gruppo->tags()->detach();
}
if ($request->has('individui')) { if ($request->has('individui')) {
$gruppo->individui()->detach(); $gruppo->individui()->detach();
foreach ($request->individui as $individuoData) { foreach ($request->individui as $individuoData) {
@@ -300,4 +371,156 @@ class GruppoController extends Controller
return redirect()->route('gruppi.index')->with('success', 'Vista eliminata.'); 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.");
}
} }
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\AppSetting;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class HelpController extends Controller
{
public function index(): View
{
return view('help.index', [
'appName' => AppSetting::getAppName() ?? 'Glastree',
]);
}
public function pdf(): View
{
return view('help.pdf', [
'appName' => AppSetting::getAppName() ?? 'Glastree',
]);
}
public function downloadPdf(): BinaryFileResponse
{
$pdfPath = storage_path('app/help-guida.pdf');
if (!file_exists($pdfPath)) {
$appName = AppSetting::getAppName() ?? 'Glastree';
$html = view('help.pdf', ['appName' => $appName])->render();
$tmpHtml = tempnam(sys_get_temp_dir(), 'help_') . '.html';
file_put_contents($tmpHtml, $html);
$cmd = sprintf(
'/usr/bin/chromium --headless --no-sandbox --disable-gpu --print-to-pdf=%s %s 2>&1',
escapeshellarg($pdfPath),
escapeshellarg('file://' . $tmpHtml)
);
exec($cmd, $execOutput, $exitCode);
unlink($tmpHtml);
if ($exitCode !== 0 || !file_exists($pdfPath)) {
abort(500, 'Errore generazione PDF. Assicurati che Chromium sia installato.');
}
}
return response()->download($pdfPath, 'guida-glastree.pdf', [
'Content-Type' => 'application/pdf',
]);
}
}
+18 -1
View File
@@ -1,10 +1,17 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Individuo; use App\Models\Documento;
use App\Models\Evento;
use App\Models\Gruppo; use App\Models\Gruppo;
use App\Models\Individuo;
use App\Models\EmailMessage;
use App\Models\MailingList;
use App\Models\Notifica; use App\Models\Notifica;
use App\Services\BackupService;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
class HomeController extends Controller class HomeController extends Controller
@@ -13,10 +20,20 @@ class HomeController extends Controller
{ {
$user = Auth::user(); $user = Auth::user();
$backupsCount = 0;
if ($user->canManage('settings')) {
$backupsCount = count(app(BackupService::class)->list());
}
$stats = [ $stats = [
'individui' => Individuo::count(), 'individui' => Individuo::count(),
'gruppi' => Gruppo::count(), 'gruppi' => Gruppo::count(),
'backups' => $backupsCount,
'notifiche' => $user->unreadNotificheCount(), 'notifiche' => $user->unreadNotificheCount(),
'documenti' => Documento::count(),
'eventi' => Evento::count(),
'email_nuove' => EmailMessage::where('is_read', false)->where('is_trash', false)->count(),
'mailing_liste' => MailingList::count(),
]; ];
$notifiche = $user->notifiche()->take(5)->get(); $notifiche = $user->notifiche()->take(5)->get();
+218 -25
View File
@@ -3,29 +3,44 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\AppSetting; use App\Models\AppSetting;
use App\Models\CalendarioConnessione;
use App\Models\Documento;
use App\Models\EmailSetting;
use App\Models\Ruolo; use App\Models\Ruolo;
use App\Models\SenderAccount;
use App\Models\StorageRepository;
use App\Models\Tag;
use App\Models\TipologiaDocumento; use App\Models\TipologiaDocumento;
use App\Models\TipologiaEvento; use App\Models\TipologiaEvento;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class ImpostazioniController extends Controller class ImpostazioniController extends Controller
{ {
public function index() public function index()
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$tipologie = TipologiaDocumento::orderBy('ordine')->get(); $tipologie = TipologiaDocumento::orderBy('ordine')->get();
$tipologieEventi = TipologiaEvento::orderBy('ordine')->get(); $tipologieEventi = TipologiaEvento::orderBy('ordine')->get();
$ruoli = Ruolo::orderBy('ordine')->get(); $ruoli = Ruolo::orderBy('ordine')->get();
$tags = Tag::orderBy('order_column')->get();
$appSettings = AppSetting::first(); $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')); return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'tags', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni'));
} }
public function saveAppSettings(Request $request) public function saveAppSettings(Request $request)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$validated = $request->validate([ $validated = $request->validate([
'nome_applicazione' => 'nullable|string|max:100', 'nome_applicazione' => 'nullable|string|max:100',
@@ -34,25 +49,123 @@ class ImpostazioniController extends Controller
'app_version' => 'nullable|string|max:50', 'app_version' => 'nullable|string|max:50',
'dashboard_welcome' => 'nullable|string|max:1000', 'dashboard_welcome' => 'nullable|string|max:1000',
'show_version' => 'boolean', 'show_version' => 'boolean',
'documenti_storage_disk' => 'nullable|string|in:local,public',
'documenti_storage_path' => 'nullable|string|max:255',
]); ]);
$settings = AppSetting::first() ?? new AppSetting(); $settings = AppSetting::first() ?? new AppSetting();
$settings->nome_applicazione = $validated['nome_applicazione'] ?? null; $oldDisk = $settings->documenti_storage_disk ?? 'local';
$settings->nome_organizzazione = $validated['nome_organizzazione'] ?? null; $oldPath = $settings->documenti_storage_path ?? 'documenti';
$settings->footer_text = $validated['footer_text'] ?? null;
$settings->app_version = $validated['app_version'] ?? null; $settings->nome_applicazione = $validated['nome_applicazione'] ?? $settings->nome_applicazione ?? '';
$settings->dashboard_welcome = $validated['dashboard_welcome'] ?? null; $settings->nome_organizzazione = $validated['nome_organizzazione'] ?? $settings->nome_organizzazione ?? '';
$settings->footer_text = $validated['footer_text'] ?? $settings->footer_text ?? '';
$settings->app_version = $validated['app_version'] ?? $settings->app_version ?? '';
$settings->dashboard_welcome = $validated['dashboard_welcome'] ?? $settings->dashboard_welcome ?? null;
$settings->show_version = $request->boolean('show_version'); $settings->show_version = $request->boolean('show_version');
$settings->documenti_storage_disk = $validated['documenti_storage_disk'] ?? $settings->documenti_storage_disk ?? 'local';
$settings->documenti_storage_path = $validated['documenti_storage_path'] ?? $settings->documenti_storage_path ?? 'documenti';
$settings->save(); $settings->save();
$newDisk = $settings->documenti_storage_disk;
$newPath = $settings->documenti_storage_path;
$pathChanged = $oldDisk !== $newDisk || $oldPath !== $newPath;
if ($pathChanged) {
try {
Storage::disk($newDisk)->makeDirectory($newPath);
} catch (\Exception $e) {
Log::error('Creazione directory documenti fallita: ' . $e->getMessage());
}
$fileCount = Documento::whereNull('repository_id')->count();
return redirect('/impostazioni#documenti')
->with('success', 'Percorso di archiviazione aggiornato. La nuova directory è stata creata.')
->with('path_changed', true)
->with('old_storage_disk', $oldDisk)
->with('old_storage_path', $oldPath)
->with('new_storage_disk', $newDisk)
->with('new_storage_path', $newPath)
->with('migration_count', $fileCount);
}
return redirect('/impostazioni#applicazione')->with('success', 'Impostazioni applicazione salvate.'); return redirect('/impostazioni#applicazione')->with('success', 'Impostazioni applicazione salvate.');
} }
public function migrateStoragePath(Request $request)
{
$this->authorizeWrite('settings');
$oldDisk = $request->input('old_storage_disk', 'local');
$oldPath = $request->input('old_storage_path', 'documenti');
$newDisk = $request->input('new_storage_disk', 'local');
$newPath = $request->input('new_storage_path', 'documenti');
$documents = Documento::whereNull('repository_id')->get();
$moved = 0;
$errors = [];
foreach ($documents as $doc) {
$oldFilePath = $doc->file_path;
$relativePath = $oldFilePath;
if (str_starts_with($relativePath, $oldPath . '/') || $relativePath === $oldPath) {
$relativePath = substr($relativePath, strlen($oldPath));
$relativePath = ltrim($relativePath, '/');
}
$newFilePath = $newPath . ($relativePath ? '/' . $relativePath : '');
if ($newFilePath === $oldFilePath && $oldDisk === $newDisk) {
continue;
}
try {
if (!Storage::disk($oldDisk)->exists($oldFilePath)) {
$errors[] = "File non trovato: {$oldFilePath}";
continue;
}
Storage::disk($newDisk)->writeStream(
$newFilePath,
Storage::disk($oldDisk)->readStream($oldFilePath)
);
$doc->update([
'file_path' => $newFilePath,
'storage_disk' => $newDisk,
]);
Storage::disk($oldDisk)->delete($oldFilePath);
$moved++;
} catch (\Exception $e) {
$errors[] = "Errore su {$oldFilePath}: " . $e->getMessage();
Log::error('Migrazione file fallita: ' . $e->getMessage(), [
'doc_id' => $doc->id,
'old' => $oldFilePath,
'new' => $newFilePath,
]);
}
}
$message = "Migrazione completata: {$moved} file spostati.";
if (count($errors) > 0) {
$message .= ' ' . count($errors) . ' errori. Controlla i log per i dettagli.';
}
return redirect('/impostazioni#documenti')
->with('success', $message)
->with('migration_errors', count($errors) > 0 ? $errors : null);
}
public function tipologieStore(Request $request) public function tipologieStore(Request $request)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$validated = $request->validate([ $validated = $request->validate([
'nome' => 'required|string|max:50|unique:tipologie_documenti,nome', 'nome' => 'required|string|max:50|unique:tipologie_documenti,nome',
@@ -73,7 +186,7 @@ class ImpostazioniController extends Controller
public function tipologieUpdate(Request $request, $id) public function tipologieUpdate(Request $request, $id)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$tipologia = TipologiaDocumento::findOrFail($id); $tipologia = TipologiaDocumento::findOrFail($id);
@@ -90,7 +203,7 @@ class ImpostazioniController extends Controller
public function tipologieDestroy($id) public function tipologieDestroy($id)
{ {
$this->authorizeDelete('viste'); $this->authorizeDelete('settings');
$tipologia = TipologiaDocumento::findOrFail($id); $tipologia = TipologiaDocumento::findOrFail($id);
$count = $tipologia->documenti()->count(); $count = $tipologia->documenti()->count();
@@ -105,7 +218,7 @@ class ImpostazioniController extends Controller
public function tipologieReorder(Request $request) public function tipologieReorder(Request $request)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$order = $request->input('order', []); $order = $request->input('order', []);
@@ -118,7 +231,7 @@ class ImpostazioniController extends Controller
public function ruoliStore(Request $request) public function ruoliStore(Request $request)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$validated = $request->validate([ $validated = $request->validate([
'nome' => 'required|string|max:50|unique:ruoli,nome', 'nome' => 'required|string|max:50|unique:ruoli,nome',
@@ -139,7 +252,7 @@ class ImpostazioniController extends Controller
public function ruoliUpdate(Request $request, $id) public function ruoliUpdate(Request $request, $id)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$ruolo = Ruolo::findOrFail($id); $ruolo = Ruolo::findOrFail($id);
@@ -156,14 +269,21 @@ class ImpostazioniController extends Controller
public function ruoliDestroy($id) public function ruoliDestroy($id)
{ {
$this->authorizeDelete('viste'); $this->authorizeDelete('settings');
$ruolo = Ruolo::findOrFail($id); $ruolo = Ruolo::findOrFail($id);
$count = \DB::table('gruppo_individuo') $allRows = \DB::table('gruppo_individuo')
->whereNotNull('ruolo_ids') ->whereNotNull('ruolo_ids')
->whereRaw("JSON_CONTAINS(ruolo_ids, ?)", [json_encode($id)]) ->get(['id', 'ruolo_ids']);
->count();
$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) { if ($count > 0) {
return redirect('/impostazioni#ruoli')->with('error', 'Impossibile eliminare: ' . $count . ' membri associati a questo ruolo.'); return redirect('/impostazioni#ruoli')->with('error', 'Impossibile eliminare: ' . $count . ' membri associati a questo ruolo.');
@@ -176,7 +296,7 @@ class ImpostazioniController extends Controller
public function ruoliReorder(Request $request) public function ruoliReorder(Request $request)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$order = $request->input('order', []); $order = $request->input('order', []);
@@ -189,7 +309,7 @@ class ImpostazioniController extends Controller
public function tipologieEventiStore(Request $request) public function tipologieEventiStore(Request $request)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$validated = $request->validate([ $validated = $request->validate([
'nome' => 'required|string|max:50|unique:tipologie_eventi,nome', 'nome' => 'required|string|max:50|unique:tipologie_eventi,nome',
@@ -210,7 +330,7 @@ class ImpostazioniController extends Controller
public function tipologieEventiUpdate(Request $request, $id) public function tipologieEventiUpdate(Request $request, $id)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$tipologia = TipologiaEvento::findOrFail($id); $tipologia = TipologiaEvento::findOrFail($id);
@@ -227,7 +347,7 @@ class ImpostazioniController extends Controller
public function tipologieEventiDestroy($id) public function tipologieEventiDestroy($id)
{ {
$this->authorizeDelete('viste'); $this->authorizeDelete('settings');
$tipologia = TipologiaEvento::findOrFail($id); $tipologia = TipologiaEvento::findOrFail($id);
$count = $tipologia->eventi()->count(); $count = $tipologia->eventi()->count();
@@ -242,7 +362,7 @@ class ImpostazioniController extends Controller
public function tipologieEventiReorder(Request $request) public function tipologieEventiReorder(Request $request)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$order = $request->input('order', []); $order = $request->input('order', []);
@@ -253,9 +373,73 @@ class ImpostazioniController extends Controller
return response()->json(['success' => true]); 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) public function uploadLogo(Request $request)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$request->validate([ $request->validate([
'logo' => 'nullable|image|mimes:png,jpg,jpeg,gif,svg|max:2048', 'logo' => 'nullable|image|mimes:png,jpg,jpeg,gif,svg|max:2048',
@@ -280,12 +464,21 @@ class ImpostazioniController extends Controller
$settings->save(); $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.'); return redirect('/impostazioni#logo')->with('success', 'Logo caricato con successo.');
} }
public function removeLogo(Request $request) public function removeLogo(Request $request)
{ {
$this->authorizeWrite('viste'); $this->authorizeWrite('settings');
$type = $request->input('type'); $type = $request->input('type');
+137 -7
View File
@@ -5,8 +5,11 @@ namespace App\Http\Controllers;
use App\Models\Individuo; use App\Models\Individuo;
use App\Models\Contatto; use App\Models\Contatto;
use App\Models\Documento; use App\Models\Documento;
use App\Models\Gruppo;
use App\Models\Comune; use App\Models\Comune;
use App\Models\Diocesi; use App\Models\Diocesi;
use App\Models\Tag;
use App\Models\VistaReport;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -17,7 +20,17 @@ class IndividuoController extends Controller
public function index(Request $request) public function index(Request $request)
{ {
$this->authorizeRead('individui'); $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; $vista = null;
@@ -38,16 +51,43 @@ class IndividuoController extends Controller
['key' => 'codice', 'label' => 'Codice'], ['key' => 'codice', 'label' => 'Codice'],
['key' => 'cognome', 'label' => 'Cognome'], ['key' => 'cognome', 'label' => 'Cognome'],
['key' => 'nome', 'label' => 'Nome'], ['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' => 'email', 'label' => 'Email'],
['key' => 'telefono', 'label' => 'Telefono'], ['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) $visibleColumns = $vista && !empty($vista->colonne_visibili)
? $vista->colonne_visibili ? $vista->colonne_visibili
: $defaultVisible; : $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) public function exportCSV(Request $request)
@@ -109,7 +149,8 @@ public function create()
$this->authorizeWrite('individui'); $this->authorizeWrite('individui');
$comuni = Comune::orderBy('nome')->get(); $comuni = Comune::orderBy('nome')->get();
$diocesi = Diocesi::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) public function store(Request $request)
@@ -128,10 +169,16 @@ public function create()
'numero_documento' => 'nullable|string|max:50', 'numero_documento' => 'nullable|string|max:50',
'scadenza_documento' => 'nullable|date', 'scadenza_documento' => 'nullable|date',
'note' => 'nullable|string', 'note' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$individuo = Individuo::create($data); $individuo = Individuo::create($data);
if ($request->has('tags')) {
$individuo->tags()->sync($request->tags);
}
if ($request->has('contatti')) { if ($request->has('contatti')) {
foreach ($request->contatti as $contatto) { foreach ($request->contatti as $contatto) {
if (!empty($contatto['tipo']) && !empty($contatto['valore'])) { if (!empty($contatto['tipo']) && !empty($contatto['valore'])) {
@@ -153,7 +200,7 @@ public function create()
$this->authorizeRead('individui'); $this->authorizeRead('individui');
$individuo = Individuo::with(['contatti', 'gruppi' => function ($q) { $individuo = Individuo::with(['contatti', 'gruppi' => function ($q) {
$q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione'); $q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione');
}, 'avatar'])->findOrFail($individuo); }, 'avatar', 'tags'])->findOrFail($individuo);
return view('individui.show', compact('individuo')); return view('individui.show', compact('individuo'));
} }
@@ -179,8 +226,10 @@ public function create()
public function edit($individuo) public function edit($individuo)
{ {
$this->authorizeWrite('individui'); $this->authorizeWrite('individui');
$individuo = Individuo::with(['contatti', 'gruppi', 'avatar'])->findOrFail($individuo); $individuo = Individuo::with(['contatti', 'gruppi', 'avatar', 'tags'])->findOrFail($individuo);
return view('individui.edit', compact('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) public function update(Request $request, $individuo)
@@ -204,17 +253,29 @@ public function create()
'numero_documento' => 'nullable|string|max:50', 'numero_documento' => 'nullable|string|max:50',
'scadenza_documento' => 'nullable|date', 'scadenza_documento' => 'nullable|date',
'note' => 'nullable|string', 'note' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$individuo->update($data); $individuo->update($data);
if ($request->has('tags')) {
$individuo->tags()->sync($request->tags);
} else {
$individuo->tags()->detach();
}
\Log::info('Individuo dati salvati', ['id' => $individuo->id]); \Log::info('Individuo dati salvati', ['id' => $individuo->id]);
if ($request->has('contatti')) {
$contattiData = $request->input('contatti'); $contattiData = $request->input('contatti');
\Log::info('Contatti ricevuti:', ['contatti' => $contattiData, 'tipo' => gettype($contattiData)]); \Log::info('Contatti ricevuti:', ['contatti' => $contattiData, 'tipo' => gettype($contattiData)]);
$individuo->contatti()->delete(); $individuo->contatti()->delete();
\Log::info('Contatti precedenti eliminati, count ora:', ['count' => $individuo->contatti()->count()]); \Log::info('Contatti precedenti eliminati, count ora:', ['count' => $individuo->contatti()->count()]);
} else {
$contattiData = null;
}
if (!empty($contattiData) && is_array($contattiData)) { if (!empty($contattiData) && is_array($contattiData)) {
$created = 0; $created = 0;
@@ -303,6 +364,75 @@ public function create()
return redirect()->route('individui.index')->with('success', count($individui) . ' individui eliminati con successo.'); 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) public function elementiCollegati($individuo)
{ {
$individuo = Individuo::findOrFail($individuo); $individuo = Individuo::findOrFail($individuo);
+258 -8
View File
@@ -1,12 +1,17 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\MailingList; use App\Models\MailingList;
use App\Models\MailingContact; use App\Models\MailingContact;
use App\Models\MailingMessaggio;
use App\Models\SenderAccount;
use App\Models\Individuo; use App\Models\Individuo;
use App\Models\Documento; use App\Models\Documento;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class MailingController extends Controller class MailingController extends Controller
@@ -24,6 +29,7 @@ class MailingController extends Controller
$liste = MailingList::all(); $liste = MailingList::all();
$documenti = Documento::orderBy('nome_file')->get(); $documenti = Documento::orderBy('nome_file')->get();
$senderAccounts = SenderAccount::active()->get();
$documentiSelezionati = collect(); $documentiSelezionati = collect();
if ($request->has('documenti_selezionati')) { if ($request->has('documenti_selezionati')) {
@@ -31,7 +37,7 @@ class MailingController extends Controller
$documentiSelezionati = Documento::whereIn('id', $docIds)->get(); $documentiSelezionati = Documento::whereIn('id', $docIds)->get();
} }
return view('mailing.nuovo', compact('individui', 'liste', 'documenti', 'documentiSelezionati')); return view('mailing.nuovo', compact('individui', 'liste', 'documenti', 'documentiSelezionati', 'senderAccounts'));
} }
public function invia(Request $request) public function invia(Request $request)
@@ -44,8 +50,14 @@ class MailingController extends Controller
'destinatari_ids' => 'nullable|string', 'destinatari_ids' => 'nullable|string',
'documenti_selezionati' => 'nullable|string', 'documenti_selezionati' => 'nullable|string',
'allegato' => 'nullable|file|max:10240', 'allegato' => 'nullable|file|max:10240',
'mittente_id' => 'nullable|integer|exists:sender_accounts,id',
]); ]);
$sender = null;
if (!empty($data['mittente_id'])) {
$sender = SenderAccount::findOrFail($data['mittente_id']);
}
$documentiAllegati = []; $documentiAllegati = [];
if ($request->hasFile('allegato')) { if ($request->hasFile('allegato')) {
@@ -72,13 +84,98 @@ class MailingController extends Controller
$documentiAllegati = array_merge($documentiAllegati, $docIds); $documentiAllegati = array_merge($documentiAllegati, $docIds);
} }
$destinatari = []; $destinatari = collect();
if (!empty($data['destinatari_ids'])) { if (!empty($data['destinatari_ids'])) {
$ids = array_filter(array_map('trim', explode(',', $data['destinatari_ids']))); $ids = array_filter(array_map('trim', explode(',', $data['destinatari_ids'])));
$destinatari = Individuo::with('contatti')->whereIn('id', $ids)->get(); $destinatari = Individuo::with('contatti')->whereIn('id', $ids)->get();
} elseif (!empty($data['lista_id'])) {
$lista = MailingList::with('contatti.individuo.contatti')->find($data['lista_id']);
if ($lista) {
$destinatari = $lista->contatti->filter(fn($c) => $c->opt_in)->map(fn($c) => $c->individuo)->filter();
}
} }
return back()->with('success', 'Messaggio preparato per ' . count($destinatari) . ' destinatari con ' . count($documentiAllegati) . ' allegato(i).'); $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();
}
$attachmentPaths = $this->resolveMailingAttachmentPaths($documentiAllegati);
$messaggio = MailingMessaggio::create([
'mailing_list_id' => $data['lista_id'] ?? null,
'user_id' => auth()->id(),
'oggetto' => $data['oggetto'],
'corpo' => $data['corpo'],
'stato' => 'in_coda',
'totale_destinatari' => $emails->count(),
'mittente_nome' => $sender?->email_name,
'mittente_email' => $sender?->email_address,
]);
$ok = 0;
$fail = 0;
$logFalliti = [];
foreach ($emails as $email) {
try {
if ($sender) {
$sender->sendEmail($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
} else {
$this->sendViaSystem($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
}
$ok++;
} catch (\Throwable $e) {
$fail++;
$logFalliti[] = ['email' => $email, 'errore' => $e->getMessage()];
Log::error('Mailing send failed', ['email' => $email, 'error' => $e->getMessage()]);
}
}
$stato = $fail === 0 ? 'inviato' : ($ok > 0 ? 'parziale' : 'fallito');
$messaggio->update([
'stato' => $stato,
'invii_ok' => $ok,
'invii_falliti' => $fail,
'log_falliti' => $logFalliti,
'inviato_al' => now(),
]);
$senderLabel = $sender ? '"' . $sender->email_address . '"' : 'sistema predefinito';
if ($sender && $sender->verify_email && ($ok > 0 || $stato === 'fallito')) {
$reportSubject = '✅ Report Invio - ' . $data['oggetto'];
$reportBody = "Report invio mailing\n"
. "Mittente: {$sender->email_name} <{$sender->email_address}>\n"
. "Oggetto: {$data['oggetto']}\n"
. "Totale destinatari: {$emails->count()}\n"
. "Inviate con successo: {$ok}\n"
. "Fallite: {$fail}\n";
if (!empty($logFalliti)) {
$reportBody .= "\nDettaglio errori:\n";
foreach ($logFalliti as $f) {
$reportBody .= "- {$f['email']}: {$f['errore']}\n";
}
}
$sender->sendReport($reportSubject, $reportBody);
}
$msg = "Invio completato tramite {$senderLabel}: {$ok} inviate, {$fail} fallite.";
if ($ok > 0) {
return redirect('/mailing-liste')->with('success', $msg);
}
return back()->with('error', $msg);
} }
public function invio(Request $request) public function invio(Request $request)
@@ -86,8 +183,9 @@ class MailingController extends Controller
$this->authorizeWrite('mailing'); $this->authorizeWrite('mailing');
$liste = MailingList::where('attiva', true)->orderBy('nome')->get(); $liste = MailingList::where('attiva', true)->orderBy('nome')->get();
$documenti = Documento::orderBy('nome_file')->get(); $documenti = Documento::orderBy('nome_file')->get();
$senderAccounts = SenderAccount::active()->get();
return view('mailing.invio', compact('liste', 'documenti')); return view('mailing.invio', compact('liste', 'documenti', 'senderAccounts'));
} }
public function invioElabora(Request $request) public function invioElabora(Request $request)
@@ -101,8 +199,14 @@ class MailingController extends Controller
'documenti_selezionati' => 'nullable|array', 'documenti_selezionati' => 'nullable|array',
'documenti_selezionati.*' => 'exists:documenti,id', 'documenti_selezionati.*' => 'exists:documenti,id',
'allegato' => 'nullable|file|max:10240', 'allegato' => 'nullable|file|max:10240',
'mittente_id' => 'nullable|integer|exists:sender_accounts,id',
]); ]);
$sender = null;
if (!empty($data['mittente_id'])) {
$sender = SenderAccount::findOrFail($data['mittente_id']);
}
$documentiAllegati = []; $documentiAllegati = [];
if ($request->hasFile('allegato')) { if ($request->hasFile('allegato')) {
@@ -133,10 +237,156 @@ class MailingController extends Controller
->with('individuo.contatti') ->with('individuo.contatti')
->get(); ->get();
$destinatari = $contatti->map(function($c) { $totaleContatti = $contatti->count();
return $c->individuo->contatti->where('tipo', 'email')->first()?->valore; $emails = $contatti->map(fn($c) => $c->individuo?->contatti->where('tipo', 'email')->first()?->valore)
})->filter()->unique()->values(); ->filter()->unique()->values();
return back()->with('success', 'Messaggio pronto per l\'invio a ' . $destinatari->count() . ' destinatari unici da ' . count($data['liste']) . ' lista(e).'); $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.');
}
$attachmentPaths = $this->resolveMailingAttachmentPaths($documentiAllegati);
$listeNomi = MailingList::whereIn('id', $data['liste'])->pluck('nome')->implode(', ');
$messaggio = MailingMessaggio::create([
'mailing_list_id' => null,
'user_id' => auth()->id(),
'oggetto' => $data['oggetto'],
'corpo' => $data['corpo'],
'stato' => 'in_coda',
'totale_destinatari' => $emails->count(),
'mittente_nome' => $sender?->email_name,
'mittente_email' => $sender?->email_address,
]);
$ok = 0;
$fail = 0;
$logFalliti = [];
foreach ($emails as $email) {
try {
if ($sender) {
$sender->sendEmail($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
} else {
$this->sendViaSystem($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
}
$ok++;
} catch (\Throwable $e) {
$fail++;
$logFalliti[] = ['email' => $email, 'errore' => $e->getMessage()];
Log::error('Mailing bulk send failed', ['email' => $email, 'error' => $e->getMessage()]);
}
}
$stato = $fail === 0 ? 'inviato' : ($ok > 0 ? 'parziale' : 'fallito');
$messaggio->update([
'stato' => $stato,
'invii_ok' => $ok,
'invii_falliti' => $fail,
'log_falliti' => $logFalliti,
'inviato_al' => now(),
]);
$senderLabel = $sender ? '"' . $sender->email_address . '"' : 'sistema predefinito';
if ($sender && $sender->verify_email && ($ok > 0 || $stato === 'fallito')) {
$reportSubject = '✅ Report Invio Massivo - ' . $data['oggetto'];
$reportBody = "Report invio massivo\n"
. "Mittente: {$sender->email_name} <{$sender->email_address}>\n"
. "Liste: {$listeNomi}\n"
. "Oggetto: {$data['oggetto']}\n"
. "Totale destinatari: {$emails->count()}\n"
. "Inviate con successo: {$ok}\n"
. "Fallite: {$fail}\n";
if (!empty($logFalliti)) {
$reportBody .= "\nDettaglio errori:\n";
foreach ($logFalliti as $f) {
$reportBody .= "- {$f['email']}: {$f['errore']}\n";
}
}
$sender->sendReport($reportSubject, $reportBody);
}
$msg = "Invio massivo completato tramite {$senderLabel}: {$ok} inviate, {$fail} fallite.";
if ($ok > 0) {
return redirect('/')->with('success', $msg);
}
return back()->with('error', $msg);
}
private function sendViaSystem(string $to, string $subject, string $body, array $attachmentPaths = []): void
{
$settings = \App\Models\EmailSetting::getActive();
if (!$settings) {
throw new \Exception('Account email principale non configurato.');
}
$smtpPassword = $settings->getDecryptedSmtpPassword() ?? $settings->getDecryptedPassword();
$encryption = $settings->smtp_encryption ?? 'tls';
$scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp';
$dsn = sprintf(
'%s://%s:%s@%s:%d?encryption=%s',
$scheme,
rawurlencode($settings->smtp_username ?? $settings->email_address),
rawurlencode($smtpPassword),
$settings->smtp_host ?? 'smtp.gmail.com',
$settings->smtp_port ?? 587,
$encryption
);
$transport = \Symfony\Component\Mailer\Transport::fromDsn($dsn);
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
$fromName = $settings->email_name ?? '';
if (filter_var($fromName, FILTER_VALIDATE_EMAIL)) {
$fromName = 'Glastree';
} else {
$fromName = preg_replace('/[^\p{L}\p{N}\s]/u', '', $fromName);
$fromName = trim($fromName);
if (empty($fromName) || strlen($fromName) > 50) {
$fromName = 'Glastree';
}
}
$fromAddress = \Symfony\Component\Mime\Address::create($settings->email_address, $fromName);
$email = (new \Symfony\Component\Mime\Email())
->from($fromAddress)
->to($to)
->subject($subject)
->text($body);
foreach ($attachmentPaths as $path) {
$email->attachFromPath($path);
}
$mailer->send($email);
}
private function resolveMailingAttachmentPaths(array $documentIds): array
{
$paths = [];
if (empty($documentIds)) {
return $paths;
}
$docs = Documento::whereIn('id', $documentIds)->get();
foreach ($docs as $doc) {
if ($doc->file_path && Storage::exists($doc->file_path)) {
$paths[] = Storage::path($doc->file_path);
}
}
return $paths;
} }
} }
+66 -9
View File
@@ -3,23 +3,64 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\MailingList; use App\Models\MailingList;
use App\Models\Tag;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class MailingListController extends Controller class MailingListController extends Controller
{ {
public function index() public function index(Request $request)
{ {
$this->authorizeRead('mailing'); $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') ->orderBy('nome')
->get(); ->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() public function create()
{ {
$this->authorizeWrite('mailing'); $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) public function store(Request $request)
@@ -30,6 +71,8 @@ class MailingListController extends Controller
'descrizione' => 'nullable|string', 'descrizione' => 'nullable|string',
'attiva' => 'boolean', 'attiva' => 'boolean',
'contatti_json' => 'nullable|string', 'contatti_json' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$data['user_id'] = auth()->id(); $data['user_id'] = auth()->id();
@@ -42,11 +85,15 @@ class MailingListController extends Controller
'user_id' => $data['user_id'], 'user_id' => $data['user_id'],
]); ]);
if ($request->has('tags')) {
$lista->tags()->sync($request->tags);
}
if (!empty($data['contatti_json'])) { if (!empty($data['contatti_json'])) {
$contatti = json_decode($data['contatti_json'], true); $contatti = json_decode($data['contatti_json'], true);
if (is_array($contatti)) { if (is_array($contatti)) {
foreach ($contatti as $contatto) { foreach ($contatti as $contatto) {
if (!empty($contatto['individuo_id']) && !empty($contatto['email'])) { if (!empty($contatto['individuo_id'])) {
$lista->contatti()->create([ $lista->contatti()->create([
'individuo_id' => $contatto['individuo_id'], 'individuo_id' => $contatto['individuo_id'],
'opt_in' => true, 'opt_in' => true,
@@ -63,15 +110,17 @@ class MailingListController extends Controller
public function show($mailing_liste) public function show($mailing_liste)
{ {
$this->authorizeRead('mailing'); $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')); return view('mailing-liste.show', compact('mailingList'));
} }
public function edit($mailingList) public function edit($mailingList)
{ {
$this->authorizeWrite('mailing'); $this->authorizeWrite('mailing');
$mailingList = MailingList::with('contatti.individuo.contatti')->findOrFail($mailingList); $mailingList = MailingList::with(['contatti.individuo.contatti', 'tags'])->findOrFail($mailingList);
return view('mailing-liste.edit', compact('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) public function update(Request $request, $mailingList)
@@ -84,6 +133,8 @@ class MailingListController extends Controller
'descrizione' => 'nullable|string', 'descrizione' => 'nullable|string',
'attiva' => 'boolean', 'attiva' => 'boolean',
'contatti_json' => 'nullable|string', 'contatti_json' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
$data['attiva'] = $data['attiva'] ?? false; $data['attiva'] = $data['attiva'] ?? false;
@@ -94,6 +145,12 @@ class MailingListController extends Controller
'attiva' => $data['attiva'], 'attiva' => $data['attiva'],
]); ]);
if ($request->has('tags')) {
$mailingList->tags()->sync($request->tags);
} else {
$mailingList->tags()->detach();
}
if (!empty($data['contatti_json'])) { if (!empty($data['contatti_json'])) {
$newContatti = json_decode($data['contatti_json'], true); $newContatti = json_decode($data['contatti_json'], true);
if (is_array($newContatti)) { if (is_array($newContatti)) {
@@ -107,7 +164,7 @@ class MailingListController extends Controller
$toAdd = array_diff($newIds, $existingIds); $toAdd = array_diff($newIds, $existingIds);
foreach ($newContatti as $contatto) { foreach ($newContatti as $contatto) {
if (in_array($contatto['individuo_id'], $toAdd) && !empty($contatto['email'])) { if (in_array($contatto['individuo_id'], $toAdd)) {
$mailingList->contatti()->create([ $mailingList->contatti()->create([
'individuo_id' => $contatto['individuo_id'], 'individuo_id' => $contatto['individuo_id'],
'opt_in' => true, 'opt_in' => true,
+567 -73
View File
@@ -1,15 +1,22 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Individuo; use App\Helpers\ReportColumnRegistry;
use App\Models\Gruppo; use App\Models\Gruppo;
use App\Models\Individuo;
use App\Models\Evento; use App\Models\Evento;
use App\Models\Documento; use App\Models\Documento;
use App\Models\MailingList; use App\Models\MailingList;
use App\Models\Contatto; use App\Models\Contatto;
use App\Models\Tag;
use App\Models\TipologiaDocumento;
use App\Models\ReportCustom;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ReportController extends Controller class ReportController extends Controller
{ {
@@ -30,11 +37,27 @@ class ReportController extends Controller
$prebuiltReports = $this->getPrebuiltReports(); $prebuiltReports = $this->getPrebuiltReports();
$customReports = \App\Models\ReportCustom::where('user_id', auth()->id()) $customReports = ReportCustom::where('user_id', auth()->id())
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->get(); ->get();
return view('report.index', compact('stats', 'prebuiltReports', 'customReports')); $tipologieDocumento = TipologiaDocumento::attive()->pluck('nome');
$columnOptions = [];
foreach (ReportColumnRegistry::allEntityTypes() as $type) {
$columnOptions[$type] = ReportColumnRegistry::getColumns($type);
}
$allTags = Tag::orderBy('name')->get();
return view('report.index', compact(
'stats',
'prebuiltReports',
'customReports',
'tipologieDocumento',
'columnOptions',
'allTags'
));
} }
public function run(Request $request) public function run(Request $request)
@@ -44,28 +67,24 @@ class ReportController extends Controller
} }
$reportType = $request->input('report'); $reportType = $request->input('report');
$customReportId = $request->input('custom_id');
$data = match ($reportType) { if ($customReportId) {
'individui_per_genere' => $this->reportIndividuiPerGenere(), $report = ReportCustom::findOrFail($customReportId);
'individui_per_eta' => $this->reportIndividuiPerEta(), if ($report->user_id !== auth()->id()) {
'gruppi_gerarchia' => $this->reportGruppiGerarchia(), abort(403);
'gruppi_membri' => $this->reportGruppiMembri(), }
'eventi_calendario' => $this->reportEventiCalendario(), $data = $this->runCustomReport($report);
'documenti_per_tipo' => $this->reportDocumentiPerTipo(), return view('report.result', compact('data', 'reportType', 'report'));
'contatti_per_tipo' => $this->reportContattiPerTipo(), }
'mailing_list_dettaglio' => $this->reportMailingListDettaglio(),
'individui_senza_contatti' => $this->reportIndividuiSenzaContatti(), $data = $this->resolveReport($request);
'individui_senza_gruppo' => $this->reportIndividuiSenzaGruppo(),
'eventi_per_gruppo' => $this->reportEventiPerGruppo(),
'documenti_orfani' => $this->reportDocumentiOrfani(),
default => null,
};
if (!$data) { if (!$data) {
return redirect()->route('report.index')->with('error', 'Report non trovato.'); return redirect()->route('report.index')->with('error', 'Report non trovato.');
} }
return view('report.result', compact('reportType', 'data')); return view('report.result', compact('data', 'reportType'));
} }
public function runCustom($id) public function runCustom($id)
@@ -74,7 +93,7 @@ class ReportController extends Controller
abort(403); abort(403);
} }
$report = \App\Models\ReportCustom::findOrFail($id); $report = ReportCustom::findOrFail($id);
if ($report->user_id !== auth()->id()) { if ($report->user_id !== auth()->id()) {
abort(403); abort(403);
@@ -95,15 +114,46 @@ class ReportController extends Controller
'nome' => 'required|string|max:255', 'nome' => 'required|string|max:255',
'descrizione' => 'nullable|string', 'descrizione' => 'nullable|string',
'tipo_report' => 'required|string', 'tipo_report' => 'required|string',
'config' => 'nullable|array',
]); ]);
\App\Models\ReportCustom::create([ $config = [];
if ($request->has('columns')) {
$columns = $request->input('columns');
$config['columns'] = is_array($columns) ? $columns : array_map('trim', explode(',', $columns));
}
if ($request->filled('sort_by')) {
$config['sort_by'] = $request->input('sort_by');
$config['sort_direction'] = $request->input('sort_direction', 'asc');
}
if ($request->filled('limit')) {
$config['limit'] = (int) $request->input('limit');
}
if ($request->filled('search')) {
$config['search'] = $request->input('search');
}
if ($request->has('search_columns')) {
$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(), 'user_id' => auth()->id(),
'nome' => $data['nome'], 'nome' => $data['nome'],
'descrizione' => $data['descrizione'] ?? null, 'descrizione' => $data['descrizione'] ?? null,
'tipo_report' => $data['tipo_report'], 'tipo_report' => $data['tipo_report'],
'config' => $data['config'] ?? [], 'config' => $config,
]); ]);
return redirect()->route('report.index')->with('success', 'Report personalizzato creato.'); return redirect()->route('report.index')->with('success', 'Report personalizzato creato.');
@@ -115,7 +165,7 @@ class ReportController extends Controller
abort(403); abort(403);
} }
$report = \App\Models\ReportCustom::where('user_id', auth()->id())->findOrFail($id); $report = ReportCustom::where('user_id', auth()->id())->findOrFail($id);
$report->delete(); $report->delete();
return redirect()->route('report.index')->with('success', 'Report personalizzato eliminato.'); return redirect()->route('report.index')->with('success', 'Report personalizzato eliminato.');
@@ -127,39 +177,24 @@ class ReportController extends Controller
abort(403); abort(403);
} }
$reportType = $request->input('report');
$customReportId = $request->input('custom_id'); $customReportId = $request->input('custom_id');
if ($customReportId) { if ($customReportId) {
$report = \App\Models\ReportCustom::findOrFail($customReportId); $report = ReportCustom::findOrFail($customReportId);
if ($report->user_id !== auth()->id()) { if ($report->user_id !== auth()->id()) {
abort(403); abort(403);
} }
$data = $this->runCustomReport($report); $data = $this->runCustomReport($report);
return view('report.print-preview', compact('reportType', 'data', 'report')); return view('report.print-preview', compact('data', 'report'));
} }
$data = match ($reportType) { $data = $this->resolveReport($request);
'individui_per_genere' => $this->reportIndividuiPerGenere(),
'individui_per_eta' => $this->reportIndividuiPerEta(),
'gruppi_gerarchia' => $this->reportGruppiGerarchia(),
'gruppi_membri' => $this->reportGruppiMembri(),
'eventi_calendario' => $this->reportEventiCalendario(),
'documenti_per_tipo' => $this->reportDocumentiPerTipo(),
'contatti_per_tipo' => $this->reportContattiPerTipo(),
'mailing_list_dettaglio' => $this->reportMailingListDettaglio(),
'individui_senza_contatti' => $this->reportIndividuiSenzaContatti(),
'individui_senza_gruppo' => $this->reportIndividuiSenzaGruppo(),
'eventi_per_gruppo' => $this->reportEventiPerGruppo(),
'documenti_orfani' => $this->reportDocumentiOrfani(),
default => null,
};
if (!$data) { if (!$data) {
return redirect()->route('report.index')->with('error', 'Report non trovato.'); return redirect()->route('report.index')->with('error', 'Report non trovato.');
} }
return view('report.print-preview', compact('reportType', 'data')); return view('report.print-preview', compact('data'));
} }
public function exportCSV(Request $request) public function exportCSV(Request $request)
@@ -168,30 +203,26 @@ class ReportController extends Controller
abort(403); abort(403);
} }
$reportType = $request->input('report'); $customReportId = $request->input('custom_id');
$data = match ($reportType) {
'individui_per_genere' => $this->reportIndividuiPerGenere(),
'individui_per_eta' => $this->reportIndividuiPerEta(),
'gruppi_gerarchia' => $this->reportGruppiGerarchia(),
'gruppi_membri' => $this->reportGruppiMembri(),
'eventi_calendario' => $this->reportEventiCalendario(),
'documenti_per_tipo' => $this->reportDocumentiPerTipo(),
'contatti_per_tipo' => $this->reportContattiPerTipo(),
'mailing_list_dettaglio' => $this->reportMailingListDettaglio(),
'individui_senza_contatti' => $this->reportIndividuiSenzaContatti(),
'individui_senza_gruppo' => $this->reportIndividuiSenzaGruppo(),
'eventi_per_gruppo' => $this->reportEventiPerGruppo(),
'documenti_orfani' => $this->reportDocumentiOrfani(),
default => null,
};
if ($customReportId) {
$report = ReportCustom::findOrFail($customReportId);
if ($report->user_id !== auth()->id()) {
abort(403);
}
$data = $this->runCustomReport($report);
$filename = 'report_' . Str::slug($report->nome) . '_' . now()->format('Y-m-d') . '.csv';
} else {
$data = $this->resolveReport($request);
if (!$data) { if (!$data) {
return redirect()->route('report.index')->with('error', 'Report non trovato.'); return redirect()->route('report.index')->with('error', 'Report non trovato.');
} }
$filename = 'report_' . $request->input('report') . '_' . now()->format('Y-m-d') . '.csv';
}
$response = new \Symfony\Component\HttpFoundation\StreamedResponse(function () use ($data) { $response = new \Symfony\Component\HttpFoundation\StreamedResponse(function () use ($data) {
$handle = fopen('php://output', 'w'); $handle = fopen('php://output', 'w');
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
if (!empty($data['headers']) && !empty($data['rows'])) { if (!empty($data['headers']) && !empty($data['rows'])) {
fputcsv($handle, $data['headers']); fputcsv($handle, $data['headers']);
@@ -204,11 +235,42 @@ class ReportController extends Controller
}); });
$response->headers->set('Content-Type', 'text/csv; charset=utf-8'); $response->headers->set('Content-Type', 'text/csv; charset=utf-8');
$response->headers->set('Content-Disposition', 'attachment; filename="report_' . $reportType . '_' . now()->format('Y-m-d') . '.csv"'); $response->headers->set('Content-Disposition', 'attachment; filename="' . $filename . '"');
return $response; return $response;
} }
protected function resolveReport(Request $request): ?array
{
$reportType = $request->input('report');
$tipologiaFilter = $request->input('tipologia_documento');
return match ($reportType) {
'individui_per_genere' => $this->reportIndividuiPerGenere(),
'individui_per_eta' => $this->reportIndividuiPerEta(),
'gruppi_gerarchia' => $this->reportGruppiGerarchia(),
'gruppi_membri' => $this->reportGruppiMembri(),
'eventi_calendario' => $this->reportEventiCalendario(),
'documenti_per_tipo' => $this->reportDocumentiPerTipo(),
'contatti_per_tipo' => $this->reportContattiPerTipo(),
'mailing_list_dettaglio' => $this->reportMailingListDettaglio(),
'individui_senza_contatti' => $this->reportIndividuiSenzaContatti(),
'individui_senza_gruppo' => $this->reportIndividuiSenzaGruppo(),
'eventi_per_gruppo' => $this->reportEventiPerGruppo(),
'documenti_orfani' => $this->reportDocumentiOrfani(),
'individui_completo' => $this->reportIndividuiCompleto(),
'gruppi_completo' => $this->reportGruppiCompleto(),
'eventi_completo' => $this->reportEventiCompleto(),
'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,
};
}
protected function getPrebuiltReports(): array protected function getPrebuiltReports(): array
{ {
return [ return [
@@ -218,6 +280,7 @@ class ReportController extends Controller
'descrizione' => 'Distribuzione degli individui per genere (Maschio/Femmina)', 'descrizione' => 'Distribuzione degli individui per genere (Maschio/Femmina)',
'icona' => 'fa-venus-mars', 'icona' => 'fa-venus-mars',
'colore' => 'info', 'colore' => 'info',
'has_tipologia_filter' => false,
], ],
[ [
'id' => 'individui_per_eta', 'id' => 'individui_per_eta',
@@ -225,6 +288,7 @@ class ReportController extends Controller
'descrizione' => 'Distribuzione degli individui per fasce d\'età', 'descrizione' => 'Distribuzione degli individui per fasce d\'età',
'icona' => 'fa-birthday-cake', 'icona' => 'fa-birthday-cake',
'colore' => 'success', 'colore' => 'success',
'has_tipologia_filter' => false,
], ],
[ [
'id' => 'gruppi_gerarchia', 'id' => 'gruppi_gerarchia',
@@ -232,6 +296,7 @@ class ReportController extends Controller
'descrizione' => 'Struttura ad albero completa di tutti i gruppi con livelli', 'descrizione' => 'Struttura ad albero completa di tutti i gruppi con livelli',
'icona' => 'fa-sitemap', 'icona' => 'fa-sitemap',
'colore' => 'warning', 'colore' => 'warning',
'has_tipologia_filter' => false,
], ],
[ [
'id' => 'gruppi_membri', 'id' => 'gruppi_membri',
@@ -239,6 +304,7 @@ class ReportController extends Controller
'descrizione' => 'Elenco di tutti i gruppi con il conteggio dei membri', 'descrizione' => 'Elenco di tutti i gruppi con il conteggio dei membri',
'icona' => 'fa-users', 'icona' => 'fa-users',
'colore' => 'primary', 'colore' => 'primary',
'has_tipologia_filter' => false,
], ],
[ [
'id' => 'eventi_calendario', 'id' => 'eventi_calendario',
@@ -246,6 +312,7 @@ class ReportController extends Controller
'descrizione' => 'Tutti gli eventi programmati per il mese corrente', 'descrizione' => 'Tutti gli eventi programmati per il mese corrente',
'icona' => 'fa-calendar-alt', 'icona' => 'fa-calendar-alt',
'colore' => 'danger', 'colore' => 'danger',
'has_tipologia_filter' => false,
], ],
[ [
'id' => 'documenti_per_tipo', 'id' => 'documenti_per_tipo',
@@ -253,6 +320,7 @@ class ReportController extends Controller
'descrizione' => 'Distribuzione dei documenti per tipologia', 'descrizione' => 'Distribuzione dei documenti per tipologia',
'icona' => 'fa-file-alt', 'icona' => 'fa-file-alt',
'colore' => 'secondary', 'colore' => 'secondary',
'has_tipologia_filter' => false,
], ],
[ [
'id' => 'contatti_per_tipo', 'id' => 'contatti_per_tipo',
@@ -260,6 +328,7 @@ class ReportController extends Controller
'descrizione' => 'Distribuzione dei contatti per tipo (telefono, email, ecc.)', 'descrizione' => 'Distribuzione dei contatti per tipo (telefono, email, ecc.)',
'icona' => 'fa-address-book', 'icona' => 'fa-address-book',
'colore' => 'teal', 'colore' => 'teal',
'has_tipologia_filter' => false,
], ],
[ [
'id' => 'mailing_list_dettaglio', 'id' => 'mailing_list_dettaglio',
@@ -267,6 +336,7 @@ class ReportController extends Controller
'descrizione' => 'Elenco completo delle mailing list con i contatti associati', 'descrizione' => 'Elenco completo delle mailing list con i contatti associati',
'icona' => 'fa-envelope', 'icona' => 'fa-envelope',
'colore' => 'purple', 'colore' => 'purple',
'has_tipologia_filter' => false,
], ],
[ [
'id' => 'individui_senza_contatti', 'id' => 'individui_senza_contatti',
@@ -274,6 +344,7 @@ class ReportController extends Controller
'descrizione' => 'Lista degli individui che non hanno nessun contatto registrato', 'descrizione' => 'Lista degli individui che non hanno nessun contatto registrato',
'icona' => 'fa-user-slash', 'icona' => 'fa-user-slash',
'colore' => 'dark', 'colore' => 'dark',
'has_tipologia_filter' => false,
], ],
[ [
'id' => 'individui_senza_gruppo', 'id' => 'individui_senza_gruppo',
@@ -281,6 +352,7 @@ class ReportController extends Controller
'descrizione' => 'Lista degli individui non associati a nessun gruppo', 'descrizione' => 'Lista degli individui non associati a nessun gruppo',
'icona' => 'fa-user-friends', 'icona' => 'fa-user-friends',
'colore' => 'orange', 'colore' => 'orange',
'has_tipologia_filter' => false,
], ],
[ [
'id' => 'eventi_per_gruppo', 'id' => 'eventi_per_gruppo',
@@ -288,6 +360,7 @@ class ReportController extends Controller
'descrizione' => 'Distribuzione degli eventi per gruppo di appartenenza', 'descrizione' => 'Distribuzione degli eventi per gruppo di appartenenza',
'icona' => 'fa-calendar-check', 'icona' => 'fa-calendar-check',
'colore' => 'pink', 'colore' => 'pink',
'has_tipologia_filter' => false,
], ],
[ [
'id' => 'documenti_orfani', 'id' => 'documenti_orfani',
@@ -295,6 +368,79 @@ class ReportController extends Controller
'descrizione' => 'Documenti non collegati a nessun individuo, gruppo o evento', 'descrizione' => 'Documenti non collegati a nessun individuo, gruppo o evento',
'icona' => 'fa-file-excel', 'icona' => 'fa-file-excel',
'colore' => 'gray', 'colore' => 'gray',
'has_tipologia_filter' => false,
],
[
'id' => 'documenti_completo',
'nome' => 'Documenti (dettaglio completo)',
'descrizione' => 'Elenco completo documenti con uploader, visibilità e filtro per tipologia',
'icona' => 'fa-file-alt',
'colore' => 'indigo',
'has_tipologia_filter' => true,
],
[
'id' => 'scadenze_documenti',
'nome' => 'Scadenze Documenti',
'descrizione' => 'Individui con scadenza documento, filtrabile per tipo documento',
'icona' => 'fa-hourglass-half',
'colore' => 'danger',
'has_tipologia_filter' => true,
],
[
'id' => 'individui_completo',
'nome' => 'Anagrafica Completa',
'descrizione' => 'Elenco completo individui con contatti email e gruppi di appartenenza',
'icona' => 'fa-address-card',
'colore' => 'info',
'has_tipologia_filter' => false,
],
[
'id' => 'gruppi_completo',
'nome' => 'Gruppi (dettaglio completo)',
'descrizione' => 'Gruppi con conteggio membri, diocesi e sottogruppi',
'icona' => 'fa-sitemap',
'colore' => 'warning',
'has_tipologia_filter' => false,
],
[
'id' => 'eventi_completo',
'nome' => 'Eventi (dettaglio completo)',
'descrizione' => 'Tutti gli eventi con gruppi associati, responsabili e luogo',
'icona' => 'fa-calendar-check',
'colore' => 'success',
'has_tipologia_filter' => false,
],
[
'id' => 'contatti_completo',
'nome' => 'Contatti (dettaglio completo)',
'descrizione' => 'Elenco completo contatti con individuo associato',
'icona' => 'fa-address-book',
'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,
], ],
]; ];
} }
@@ -361,7 +507,7 @@ class ReportController extends Controller
protected function reportGruppiGerarchia(): array protected function reportGruppiGerarchia(): array
{ {
$rootGruppi = Gruppo::whereNull('parent_id')->with(['children.diocesi', 'children.children'])->orderBy('nome')->get(); $rootGruppi = Gruppo::whereNull('parent_id')->with(['children', 'diocesi'])->orderBy('nome')->get();
$rows = []; $rows = [];
@@ -462,7 +608,7 @@ class ReportController extends Controller
return [ return [
'title' => 'Documenti per Tipologia', 'title' => 'Documenti per Tipologia',
'headers' => ['Tipologia', 'Totale'], 'headers' => ['Tipologia', 'Totale'],
'rows' => $rows->map(fn($r) => [ucfirst($r->tipologia), $r->totale])->toArray(), 'rows' => $rows->map(fn($r) => [ucfirst((string) $r->tipologia), $r->totale])->toArray(),
'summary' => 'Totale documenti: ' . Documento::count(), 'summary' => 'Totale documenti: ' . Documento::count(),
'detail_rows' => $rows->map(fn($r) => (array) $r)->toArray(), 'detail_rows' => $rows->map(fn($r) => (array) $r)->toArray(),
]; ];
@@ -478,7 +624,7 @@ class ReportController extends Controller
return [ return [
'title' => 'Contatti per Tipo', 'title' => 'Contatti per Tipo',
'headers' => ['Tipo', 'Totale'], 'headers' => ['Tipo', 'Totale'],
'rows' => $rows->map(fn($r) => [ucfirst($r->tipo), $r->totale])->toArray(), 'rows' => $rows->map(fn($r) => [ucfirst((string) $r->tipo), $r->totale])->toArray(),
'summary' => 'Totale contatti: ' . Contatto::count(), 'summary' => 'Totale contatti: ' . Contatto::count(),
'detail_rows' => $rows->map(fn($r) => (array) $r)->toArray(), 'detail_rows' => $rows->map(fn($r) => (array) $r)->toArray(),
]; ];
@@ -495,7 +641,7 @@ class ReportController extends Controller
'lista' => $lista->nome, 'lista' => $lista->nome,
'stato' => $lista->attiva ? 'Attiva' : 'Disattiva', 'stato' => $lista->attiva ? 'Attiva' : 'Disattiva',
'individuo' => $contatto->individuo?->nome_completo ?? 'N/D', 'individuo' => $contatto->individuo?->nome_completo ?? 'N/D',
'email' => $contatto->email ?? '-', 'email' => $contatto->valore ?? '-',
]; ];
} }
} }
@@ -603,8 +749,8 @@ class ReportController extends Controller
$rows = $documenti->map(function ($doc) { $rows = $documenti->map(function ($doc) {
return [ return [
'nome' => $doc->nome_file, 'nome' => $doc->nome_file,
'tipologia' => ucfirst($doc->tipologia), 'tipologia' => ucfirst($doc->tipologia ?? ''),
'visibilita' => ucfirst($doc->visibilita), 'visibilita' => ucfirst($doc->visibilita ?? ''),
'data' => $doc->created_at->format('d/m/Y'), 'data' => $doc->created_at->format('d/m/Y'),
]; ];
})->toArray(); })->toArray();
@@ -618,7 +764,305 @@ class ReportController extends Controller
]; ];
} }
protected function runCustomReport(\App\Models\ReportCustom $report): array protected function reportDocumentiCompleto(?string $tipologiaFilter = null): array
{
$query = Documento::with('user')
->orderBy('created_at', 'desc');
if ($tipologiaFilter) {
$query->where('tipologia', $tipologiaFilter);
}
$documenti = $query->get();
$rows = $documenti->map(function ($doc) {
return [
'nome' => $doc->nome_file,
'tipologia' => ucfirst($doc->tipologia ?? ''),
'visibilita' => ucfirst($doc->visibilita ?? ''),
'dimensione' => $doc->dimensione ? number_format($doc->dimensione / 1024, 1) . ' KB' : '-',
'uploader' => $doc->user?->name ?? '-',
'data' => $doc->created_at->format('d/m/Y'),
];
})->toArray();
$title = $tipologiaFilter
? 'Documenti (tipologia: ' . ucfirst($tipologiaFilter) . ')'
: 'Documenti (dettaglio completo)';
return [
'title' => $title,
'headers' => ['Nome File', 'Tipologia', 'Visibilità', 'Dimensione', 'Caricato Da', 'Data'],
'rows' => array_map(fn($r) => [$r['nome'], $r['tipologia'], $r['visibilita'], $r['dimensione'], $r['uploader'], $r['data']], $rows),
'summary' => 'Totale documenti: ' . $documenti->count(),
'detail_rows' => $rows,
];
}
protected function reportScadenzeDocumenti(?string $tipologiaFilter = null): array
{
$query = Individuo::whereNotNull('scadenza_documento')
->whereNotNull('tipo_documento')
->orderBy('scadenza_documento');
if ($tipologiaFilter) {
$query->where('tipo_documento', $tipologiaFilter);
}
$individui = $query->get();
$rows = $individui->map(function ($ind) {
$scadenza = $ind->scadenza_documento;
$giorniMancanti = $scadenza ? now()->diffInDays($scadenza, false) : null;
return [
'codice' => $ind->codice_id,
'cognome' => $ind->cognome,
'nome' => $ind->nome,
'tipo_documento' => ucfirst($ind->tipo_documento ?? ''),
'numero' => $ind->numero_documento ?? '-',
'scadenza' => $scadenza?->format('d/m/Y') ?? '-',
'stato' => $giorniMancanti !== null
? ($giorniMancanti < 0
? ('Scaduto da ' . abs($giorniMancanti) . ' gg')
: ($giorniMancanti <= 30
? 'Scade tra ' . $giorniMancanti . ' gg'
: 'In regola (' . $giorniMancanti . ' gg)'))
: '-',
];
})->toArray();
$title = $tipologiaFilter
? 'Scadenze Documenti (tipo: ' . ucfirst($tipologiaFilter) . ')'
: 'Scadenze Documenti';
return [
'title' => $title,
'headers' => ['Codice', 'Cognome', 'Nome', 'Tipo Documento', 'Numero', 'Scadenza', 'Stato'],
'rows' => array_map(fn($r) => [$r['codice'], $r['cognome'], $r['nome'], $r['tipo_documento'], $r['numero'], $r['scadenza'], $r['stato']], $rows),
'summary' => 'Individui con documento: ' . $individui->count(),
'detail_rows' => $rows,
];
}
protected function reportIndividuiCompleto(): array
{
$individui = Individuo::with(['contatti', 'gruppi'])
->orderBy('cognome')
->orderBy('nome')
->get();
$rows = $individui->map(function ($ind) {
$email = $ind->contatti->where('tipo', 'email')->first()?->valore
?? $ind->email
?? '-';
$telefono = $ind->contatti->whereIn('tipo', ['telefono', 'cellulare'])->first()?->valore
?? $ind->telefono
?? '-';
$gruppi = $ind->gruppi->pluck('nome')->implode(', ') ?: '-';
return [
'codice' => $ind->codice_id,
'cognome' => $ind->cognome,
'nome' => $ind->nome,
'data_nascita' => $ind->data_nascita?->format('d/m/Y') ?? '-',
'genere' => match ($ind->genere) { 'M' => 'M', 'F' => 'F', default => '-' },
'email' => $email,
'telefono' => $telefono,
'città' => $ind->città ?? '-',
'gruppi' => $gruppi,
];
})->toArray();
return [
'title' => 'Anagrafica Completa Individui',
'headers' => ['Codice', 'Cognome', 'Nome', 'Data Nascita', 'Genere', 'Email', 'Telefono', 'Città', 'Gruppi'],
'rows' => array_map(fn($r) => [$r['codice'], $r['cognome'], $r['nome'], $r['data_nascita'], $r['genere'], $r['email'], $r['telefono'], $r['città'], $r['gruppi']], $rows),
'summary' => 'Totale individui: ' . $individui->count(),
'detail_rows' => $rows,
];
}
protected function reportGruppiCompleto(): array
{
$gruppi = Gruppo::withCount('individui')
->with('diocesi')
->orderBy('nome')
->get();
$rows = $gruppi->map(function ($g) {
$parent = $g->parent;
return [
'nome' => $g->nome,
'descrizione' => $g->descrizione ?? '-',
'diocesi' => $g->diocesi?->nome ?? '-',
'livello' => $g->parent_id ? (count($g->getAncestors()) + 1) : 0,
'membri' => $g->individui_count,
'padre' => $parent?->nome ?? '-',
'città' => $g->città_incontro ?? '-',
];
})->toArray();
return [
'title' => 'Gruppi (dettaglio completo)',
'headers' => ['Nome', 'Descrizione', 'Diocesi', 'Livello', 'Membri', 'Padre', 'Città'],
'rows' => array_map(fn($r) => [$r['nome'], $r['descrizione'], $r['diocesi'], $r['livello'], $r['membri'], $r['padre'], $r['città']], $rows),
'summary' => 'Totale gruppi: ' . $gruppi->count() . ' | Totale membri: ' . $gruppi->sum('individui_count'),
'detail_rows' => $rows,
];
}
protected function reportEventiCompleto(): array
{
$eventi = Evento::with(['gruppi', 'responsabili'])
->orderBy('data_specifica', 'desc')
->get();
$rows = $eventi->map(function ($e) {
return [
'nome' => $e->nome_evento,
'data' => $e->data_specifica?->format('d/m/Y') ?? '-',
'tipo_evento' => $e->tipo_evento ?? '-',
'ricorrenza' => ucfirst($e->tipo_recorrenza ?? 'singolo'),
'gruppi' => $e->gruppi->pluck('nome')->implode(', ') ?: '-',
'responsabili' => $e->responsabili->pluck('nome_completo')->implode(', ') ?: '-',
'luogo' => $e->luogo_indirizzo ?? '-',
];
})->toArray();
return [
'title' => 'Eventi (dettaglio completo)',
'headers' => ['Evento', 'Data', 'Tipo Evento', 'Ricorrenza', 'Gruppi', 'Responsabili', 'Luogo'],
'rows' => array_map(fn($r) => [$r['nome'], $r['data'], $r['tipo_evento'], $r['ricorrenza'], $r['gruppi'], $r['responsabili'], $r['luogo']], $rows),
'summary' => 'Totale eventi: ' . $eventi->count(),
'detail_rows' => $rows,
];
}
protected function reportContattiCompleto(): array
{
$contatti = Contatto::with('individuo')
->orderBy('tipo')
->orderBy('valore')
->get();
$rows = $contatti->map(function ($c) {
return [
'tipo' => ucfirst($c->tipo),
'valore' => $c->valore,
'etichetta' => $c->etichetta ?? '-',
'primario' => $c->is_primary ? 'Si' : 'No',
'individuo' => $c->individuo?->nome_completo ?? '-',
'codice' => $c->individuo?->codice_id ?? '-',
];
})->toArray();
return [
'title' => 'Contatti (dettaglio completo)',
'headers' => ['Tipo', 'Valore', 'Etichetta', 'Primario', 'Individuo', 'Codice'],
'rows' => array_map(fn($r) => [$r['tipo'], $r['valore'], $r['etichetta'], $r['primario'], $r['individuo'], $r['codice']], $rows),
'summary' => 'Totale contatti: ' . $contatti->count(),
'detail_rows' => $rows,
];
}
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; $tipo = $report->tipo_report;
$config = $report->config ?? []; $config = $report->config ?? [];
@@ -642,17 +1086,54 @@ 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];
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);
}
}
}
if (!empty($config['search'])) { if (!empty($config['search'])) {
$search = $config['search']; $search = $config['search'];
$query->where(function ($q) use ($search) { $query->where(function ($q) use ($config, $search) {
foreach ($config['search_columns'] ?? [] as $col) { foreach ($config['search_columns'] ?? [] as $col) {
if (!str_contains($col, '.')) {
$q->orWhere($col, 'like', '%' . $search . '%'); $q->orWhere($col, 'like', '%' . $search . '%');
} }
}
}); });
} }
if (!empty($config['sort_by'])) { if (!empty($config['sort_by'])) {
$query->orderBy($config['sort_by'], $config['sort_direction'] ?? 'asc'); $sortBy = $config['sort_by'];
if (!str_contains($sortBy, '.')) {
$query->orderBy($sortBy, $config['sort_direction'] ?? 'asc');
}
} }
if (!empty($config['limit'])) { if (!empty($config['limit'])) {
@@ -661,17 +1142,30 @@ class ReportController extends Controller
$results = $query->get(); $results = $query->get();
$headers = !empty($config['columns']) ? $config['columns'] : array_keys($results->first()?->toArray() ?? []); if (empty($columns)) {
$first = $results->first();
$columns = $first ? array_keys($first->toArray()) : [];
}
$rows = $results->map(function ($item) use ($headers) { $headers = array_map(function ($col) use ($tipo) {
return ReportColumnRegistry::getColumnLabel($tipo, $col) ?? $col;
}, $columns);
$rows = $results->map(function ($item) use ($columns, $tipo) {
$row = []; $row = [];
foreach ($headers as $col) { foreach ($columns as $col) {
if (str_contains($col, '.')) {
$row[] = ReportColumnRegistry::resolveRelationValue($item, $col);
} else {
$value = $item->{$col} ?? ''; $value = $item->{$col} ?? '';
if ($value instanceof \Carbon\Carbon) { if ($value instanceof \Carbon\Carbon) {
$value = $value->format('d/m/Y'); $value = $value->format('d/m/Y');
} elseif (is_bool($value)) {
$value = $value ? 'Si' : 'No';
} }
$row[] = $value; $row[] = $value;
} }
}
return $row; return $row;
})->toArray(); })->toArray();
@@ -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'));
}
}
@@ -0,0 +1,436 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\StorageRepository;
use App\Services\StorageRepositoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StorageRepositoryController extends Controller
{
public function __construct(
private readonly StorageRepositoryService $repoService
) {}
public function index(Request $request)
{
$this->authorizeWrite('settings');
$repositories = StorageRepository::orderBy('ordine')->get();
if ($request->ajax()) {
return response()->json($repositories);
}
return view('storage-repositories.index', compact('repositories'));
}
public function store(Request $request): RedirectResponse
{
$this->authorizeWrite('settings');
$validated = $request->validate([
'nome' => 'required|string|max:255',
'tipo' => 'required|string|in:webdav,google_drive',
'config' => 'required|array',
'is_active' => 'boolean',
'ordine' => 'nullable|integer|min:0',
]);
$maxOrdine = StorageRepository::max('ordine') ?? 0;
$validated['ordine'] ??= $maxOrdine + 1;
$validated['config'] = $this->repoService->encryptSensitiveConfig($validated['config'], $validated['tipo']);
StorageRepository::create($validated);
return redirect('/impostazioni#repository')->with('success', 'Repository creato.');
}
public function update(Request $request, StorageRepository $storageRepository): RedirectResponse
{
$this->authorizeWrite('settings');
$validated = $request->validate([
'nome' => 'required|string|max:255',
'tipo' => 'required|string|in:webdav,google_drive',
'config' => 'required|array',
'is_active' => 'boolean',
'ordine' => 'nullable|integer|min:0',
]);
$validated['config'] = $this->repoService->encryptSensitiveConfig(
$validated['config'],
$validated['tipo'],
$storageRepository->config
);
$storageRepository->update($validated);
return redirect('/impostazioni#repository')->with('success', 'Repository aggiornato.');
}
public function destroy(StorageRepository $storageRepository): JsonResponse|RedirectResponse
{
$this->authorizeDelete('settings');
$docCount = $storageRepository->documenti()->count();
if ($docCount > 0) {
$message = "Impossibile eliminare: {$docCount} documenti associati a questo repository.";
if (request()->expectsJson()) {
return response()->json(['success' => false, 'message' => $message], 409);
}
return redirect('/impostazioni#repository')->with('error', $message);
}
$storageRepository->delete();
if (request()->expectsJson()) {
return response()->json(['success' => true]);
}
return redirect('/impostazioni#repository')->with('success', 'Repository eliminato.');
}
public function test(StorageRepository $storageRepository): JsonResponse
{
$this->authorizeWrite('settings');
$result = $this->repoService->testConnection($storageRepository);
return response()->json($result);
}
public function browse(Request $request, StorageRepository $storageRepository): JsonResponse
{
$this->authorizeRead('documenti');
$path = $request->get('path', '/');
try {
if (!$storageRepository->is_active) {
return response()->json([
'success' => false,
'message' => 'Repository non attivo',
'contents' => []
], 400);
}
$contents = $this->repoService->listContents($storageRepository, $path);
return response()->json([
'success' => true,
'contents' => $contents,
'path' => $path,
'repository' => [
'id' => $storageRepository->id,
'nome' => $storageRepository->nome,
'tipo' => $storageRepository->tipo,
],
]);
} catch (\Exception $e) {
Log::error("StorageRepository browse failed for repo #{$storageRepository->id}: " . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Errore: ' . $e->getMessage(),
'contents' => []
], 500);
}
}
public function reorder(Request $request): JsonResponse
{
$this->authorizeWrite('settings');
$order = $request->input('order', []);
foreach ($order as $index => $id) {
StorageRepository::where('id', $id)->update(['ordine' => $index]);
}
return response()->json(['success' => true]);
}
public function downloadRemote(StorageRepository $storageRepository, Request $request): StreamedResponse
{
$this->authorizeRead('documenti');
$path = $request->get('path');
if (!$path) {
abort(400, 'Path required');
}
$filesystem = $this->repoService->buildFilesystem($storageRepository);
if (!$filesystem || !$filesystem->fileExists($path)) {
abort(404);
}
$stream = $filesystem->readStream($path);
if (!$stream) {
abort(404);
}
$mimeType = $filesystem->mimeType($path) ?? 'application/octet-stream';
$basename = basename($path);
return response()->stream(function () use ($stream) {
fpassthru($stream);
fclose($stream);
}, 200, [
'Content-Type' => $mimeType,
'Content-Disposition' => 'attachment; filename="' . $basename . '"',
]);
}
public function previewRemote(StorageRepository $storageRepository, Request $request): StreamedResponse
{
$this->authorizeRead('documenti');
$path = $request->get('path');
if (!$path) {
abort(400, 'Path required');
}
$filesystem = $this->repoService->buildFilesystem($storageRepository);
if (!$filesystem || !$filesystem->fileExists($path)) {
abort(404);
}
$stream = $filesystem->readStream($path);
if (!$stream) {
abort(404);
}
$mimeType = $filesystem->mimeType($path) ?? 'application/octet-stream';
$basename = basename($path);
return response()->stream(function () use ($stream) {
fpassthru($stream);
fclose($stream);
}, 200, [
'Content-Type' => $mimeType,
'Content-Disposition' => 'inline; filename="' . $basename . '"',
]);
}
public function importToLocal(Request $request, StorageRepository $storageRepository): JsonResponse
{
$this->authorizeWrite('documenti');
$validated = $request->validate([
'path' => 'required|string',
'basename' => 'required|string|max:255',
'cartella_id' => 'nullable|integer|exists:documenti_cartelle,id',
'tipologia' => 'nullable|string|in:avatar,galleria,documento,statuto,altro',
'visibilita' => 'nullable|string|in:pubblico,individuo,gruppo,evento,mailing',
'visibilita_target_id' => 'nullable|integer',
]);
$path = $validated['path'];
$basename = $validated['basename'];
$filesystem = $this->repoService->buildFilesystem($storageRepository);
if (!$filesystem) {
return response()->json(['success' => false, 'message' => 'Impossibile connettersi al repository remoto.'], 500);
}
$normalizedPath = $this->repoService->normalizePath($path);
if ($storageRepository->tipo === 'google_drive') {
try {
$fileData = $this->repoService->readGoogleDriveFile($storageRepository, $normalizedPath, $basename);
} catch (\Exception $e) {
Log::error("StorageRepository importToLocal: readGoogleDriveFile failed for repo #{$storageRepository->id} path '{$path}': " . $e->getMessage());
return response()->json(['success' => false, 'message' => 'Impossibile leggere il file da Google Drive: ' . $e->getMessage()], 500);
}
} else {
try {
$fileData = [];
$fileData['stream'] = $filesystem->readStream($normalizedPath);
$fileData['mimeType'] = null;
try { $fileData['mimeType'] = $filesystem->mimeType($normalizedPath); } catch (\Exception) {}
$fileData['fileSize'] = null;
try { $fileData['fileSize'] = $filesystem->fileSize($normalizedPath); } catch (\Exception) {}
$fileData['basename'] = $basename;
} catch (\Exception $e) {
Log::error("StorageRepository importToLocal: readStream failed for repo #{$storageRepository->id} path '{$path}': " . $e->getMessage());
return response()->json(['success' => false, 'message' => 'File non trovato nel repository remoto o impossibile da leggere.'], 404);
}
}
$stream = $fileData['stream'];
if (!$stream) {
return response()->json(['success' => false, 'message' => 'Impossibile leggere il file remoto.'], 500);
}
$basename = $fileData['basename'];
$mimeType = $fileData['mimeType'] ?? null;
$fileSize = $fileData['fileSize'] ?? null;
$disk = \App\Models\AppSetting::getDocumentiStorageDisk();
$storagePath = \App\Models\AppSetting::getDocumentiStoragePath();
$extension = strtolower(pathinfo($basename, PATHINFO_EXTENSION));
$nomeSenzaEstensione = pathinfo($basename, PATHINFO_FILENAME);
$uniqueName = preg_replace('/[^a-zA-Z0-9_\-\x80-\x{FF}]/u', '_', $nomeSenzaEstensione) . '_' . time() . ($extension ? '.' . $extension : '');
$filePath = $storagePath . '/' . date('Y/m/d') . '/' . $uniqueName;
$stored = \Illuminate\Support\Facades\Storage::disk($disk)->writeStream($filePath, $stream);
fclose($stream);
if (!$stored) {
return response()->json(['success' => false, 'message' => 'Errore durante il salvataggio locale del file.'], 500);
}
$visibilita = $validated['visibilita'] ?? 'pubblico';
$targetType = null;
$targetId = null;
if ($visibilita === 'individuo' && !empty($validated['visibilita_target_id'])) {
$targetType = \App\Models\Individuo::class;
$targetId = (int) $validated['visibilita_target_id'];
} elseif ($visibilita === 'gruppo' && !empty($validated['visibilita_target_id'])) {
$targetType = \App\Models\Gruppo::class;
$targetId = (int) $validated['visibilita_target_id'];
} elseif ($visibilita === 'evento' && !empty($validated['visibilita_target_id'])) {
$targetType = \App\Models\Evento::class;
$targetId = (int) $validated['visibilita_target_id'];
} elseif ($visibilita === 'mailing' && !empty($validated['visibilita_target_id'])) {
$targetType = \App\Models\MailingList::class;
$targetId = (int) $validated['visibilita_target_id'];
}
$documento = \App\Models\Documento::create([
'nome_file' => $nomeSenzaEstensione,
'file_path' => $filePath,
'storage_disk' => $disk,
'tipo' => 'upload',
'tipologia' => $validated['tipologia'] ?? 'documento',
'visibilita' => $visibilita,
'visibilita_target_id' => $targetId,
'visibilita_target_type' => $targetType,
'mime_type' => $mimeType,
'dimensione' => $fileSize,
'user_id' => auth()->id(),
'cartella_id' => $validated['cartella_id'] ?? null,
'repository_id' => null,
]);
return response()->json([
'success' => true,
'message' => 'File importato come documento locale: ' . $documento->nome_file,
'documento_id' => $documento->id,
]);
}
public function oauthRedirect(Request $request): RedirectResponse
{
$this->authorizeWrite('settings');
$clientId = $request->get('client_id');
$clientSecret = $request->get('client_secret');
$repoId = (int) $request->get('repo_id', 0);
if (empty($clientId) || empty($clientSecret)) {
return redirect('/impostazioni#repository')->with('error', 'Inserisci Client ID e Client Secret prima di autorizzare.');
}
session(['google_drive_oauth' => [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'repo_id' => $repoId,
'nome' => $request->get('nome', ''),
'is_active' => $request->get('is_active', '1'),
'root_folder_id' => $request->get('root_folder_id', 'root'),
]]);
$client = new \Google_Client();
$client->setClientId($clientId);
$client->setClientSecret($clientSecret);
$client->setRedirectUri(url('/auth/google-drive/callback'));
$client->addScope(\Google_Service_Drive::DRIVE);
$client->setAccessType('offline');
$client->setPrompt('consent');
$authUrl = $client->createAuthUrl();
return redirect()->away($authUrl);
}
public function oauthCallback(Request $request): RedirectResponse
{
$this->authorizeWrite('settings');
if ($request->get('error')) {
return redirect('/impostazioni#repository')->with('error', 'Autorizzazione Google Drive annullata o fallita.');
}
$code = $request->get('code');
if (!$code) {
return redirect('/impostazioni#repository')->with('error', 'Codice di autorizzazione mancante.');
}
$oauthData = session('google_drive_oauth');
if (!$oauthData) {
return redirect('/impostazioni#repository')->with('error', 'Sessione scaduta. Riprova l\'autorizzazione.');
}
try {
$client = new \Google_Client();
$client->setClientId($oauthData['client_id']);
$client->setClientSecret($oauthData['client_secret']);
$client->setRedirectUri(url('/auth/google-drive/callback'));
$client->addScope(\Google_Service_Drive::DRIVE);
$token = $client->fetchAccessTokenWithAuthCode($code);
if (isset($token['error'])) {
throw new \Exception($token['error_description'] ?? $token['error']);
}
$refreshToken = $client->getRefreshToken();
if (!$refreshToken) {
throw new \Exception('Refresh token non ricevuto. Assicurati che l\'app OAuth sia configurata con access_type=offline e prompt=consent nella Google Cloud Console.');
}
$repoId = $oauthData['repo_id'];
session()->forget('google_drive_oauth');
if ($repoId > 0) {
$repo = StorageRepository::find($repoId);
if ($repo) {
$config = $repo->config;
$config['client_id'] = $oauthData['client_id'];
$config['client_secret'] = $oauthData['client_secret'];
$config['refresh_token'] = $refreshToken;
$config = $this->repoService->encryptSensitiveConfig($config, 'google_drive');
$repo->update(['config' => $config]);
return redirect('/impostazioni#repository')->with('success', 'Google Drive autorizzato con successo! Refresh token salvato.');
}
return redirect('/impostazioni#repository')->with('error', 'Repository non trovato (ID: ' . $repoId . ').');
}
session()->flash('google_drive_new_token', [
'refresh_token' => $refreshToken,
'client_id' => $oauthData['client_id'],
'client_secret' => $oauthData['client_secret'],
'nome' => $oauthData['nome'] ?? '',
'is_active' => $oauthData['is_active'] ?? '1',
'root_folder_id' => $oauthData['root_folder_id'] ?? 'root',
]);
return redirect('/impostazioni#repository')->with('success', 'Autorizzazione Google Drive completata! Ora completa la creazione del repository e salva.');
} catch (\Exception $e) {
return redirect('/impostazioni#repository')->with('error', 'Errore autorizzazione Google Drive: ' . $e->getMessage());
}
}
}
+11 -1
View File
@@ -33,9 +33,11 @@ class VistaReportController extends Controller
$this->authorizeWrite('viste'); $this->authorizeWrite('viste');
$data = $request->validate([ $data = $request->validate([
'nome' => 'required|string|max:255', '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_visibili' => 'nullable|array',
'colonne_ordinamento' => 'nullable|array', 'colonne_ordinamento' => 'nullable|array',
'colonne_larghezze' => 'nullable|array',
'colonne_ordine' => 'nullable|array',
'filtri' => 'nullable|array', 'filtri' => 'nullable|array',
'ricerca' => 'nullable|string', 'ricerca' => 'nullable|string',
'is_default' => 'nullable|boolean', 'is_default' => 'nullable|boolean',
@@ -55,6 +57,8 @@ class VistaReportController extends Controller
'tipo' => $data['tipo'], 'tipo' => $data['tipo'],
'colonne_visibili' => $data['colonne_visibili'] ?? null, 'colonne_visibili' => $data['colonne_visibili'] ?? null,
'colonne_ordinamento' => $data['colonne_ordinamento'] ?? null, 'colonne_ordinamento' => $data['colonne_ordinamento'] ?? null,
'colonne_larghezze' => $data['colonne_larghezze'] ?? null,
'colonne_ordine' => $data['colonne_ordine'] ?? null,
'filtri' => $data['filtri'] ?? null, 'filtri' => $data['filtri'] ?? null,
'ricerca' => $data['ricerca'] ?? null, 'ricerca' => $data['ricerca'] ?? null,
'is_default' => $isDefault, 'is_default' => $isDefault,
@@ -78,6 +82,8 @@ class VistaReportController extends Controller
'nome' => 'required|string|max:255', 'nome' => 'required|string|max:255',
'colonne_visibili' => 'nullable|array', 'colonne_visibili' => 'nullable|array',
'colonne_ordinamento' => 'nullable|array', 'colonne_ordinamento' => 'nullable|array',
'colonne_larghezze' => 'nullable|array',
'colonne_ordine' => 'nullable|array',
'filtri' => 'nullable|array', 'filtri' => 'nullable|array',
'ricerca' => 'nullable|string', 'ricerca' => 'nullable|string',
'is_default' => 'nullable|boolean', 'is_default' => 'nullable|boolean',
@@ -92,6 +98,10 @@ class VistaReportController extends Controller
$vistaReport->update($data); $vistaReport->update($data);
if ($request->expectsJson()) {
return response()->json(['success' => true, 'message' => 'Vista aggiornata.']);
}
return back()->with('success', 'Vista aggiornata.'); return back()->with('success', 'Vista aggiornata.');
} }
+1 -1
View File
@@ -14,7 +14,7 @@ class AdminOnly
return redirect('/login'); return redirect('/login');
} }
if (!auth()->user()->isSuperAdmin()) { if (!auth()->user()->isSuperAdmin() && !auth()->user()->canManage('settings')) {
abort(403, 'Accesso riservato agli amministratori.'); abort(403, 'Accesso riservato agli amministratori.');
} }
+57 -23
View File
@@ -1,28 +1,45 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
class AppSetting extends Model class AppSetting extends Model
{ {
protected $table = 'app_settings'; protected $table = 'app_settings';
protected $fillable = [ protected $fillable = [
'logo', 'logo', 'logo_small', 'logo_path', 'logo_small_path',
'logo_small', 'nome_applicazione', 'nome_organizzazione',
'logo_path', 'footer_text', 'app_version', 'dashboard_welcome', 'show_version',
'logo_small_path', 'backup_path', 'backup_retention_days', 'backup_include_files',
'nome_applicazione', 'backup_include_env', 'backup_auto_enabled', 'backup_auto_frequency', 'backup_auto_hour',
'nome_organizzazione', 'documenti_storage_disk', 'documenti_storage_path',
'footer_text',
'app_version',
'dashboard_welcome',
'show_version',
]; ];
public static function getSetting(string $key, $default = null) protected function casts(): array
{ {
return [
'backup_retention_days' => 'integer',
'backup_include_files' => 'boolean',
'backup_include_env' => 'boolean',
'backup_auto_enabled' => 'boolean',
'backup_auto_hour' => 'integer',
'show_version' => 'boolean',
];
}
public static function getSetting(string $key, mixed $default = null): mixed
{
if (!Schema::hasTable('app_settings')) {
return $default;
}
$setting = self::first(); $setting = self::first();
return $setting?->{$key} ?? $default; return $setting?->{$key} ?? $default;
} }
@@ -73,40 +90,57 @@ class AppSetting extends Model
return (bool) self::getSetting('show_version', false); return (bool) self::getSetting('show_version', false);
} }
public static function getDocumentiStorageDisk(): string
{
return self::getSetting('documenti_storage_disk', 'local');
}
public static function getDocumentiStoragePath(): string
{
return self::getSetting('documenti_storage_path', 'documenti');
}
public static function getDocumentiStorageAbsolutePath(): string
{
$disk = self::getDocumentiStorageDisk();
$path = self::getDocumentiStoragePath();
return storage_path('app/' . ($disk === 'local' ? 'private/' : '') . $path);
}
public function getLogoUrlInstance(): ?string public function getLogoUrlInstance(): ?string
{ {
if ($this->logo && file_exists(public_path('storage/' . $this->logo))) { if ($this->logo && Storage::disk('public')->exists($this->logo)) {
return asset('storage/' . $this->logo); return Storage::disk('public')->url($this->logo);
} }
return null; return null;
} }
public function getLogoSmallUrlInstance(): ?string public function getLogoSmallUrlInstance(): ?string
{ {
if ($this->logo_small && file_exists(public_path('storage/' . $this->logo_small))) { if ($this->logo_small && Storage::disk('public')->exists($this->logo_small)) {
return asset('storage/' . $this->logo_small); return Storage::disk('public')->url($this->logo_small);
} }
return null; return null;
} }
public function getLogoPath(): ?string public function getLogoPath(): ?string
{ {
if ($this->logo_path && file_exists(public_path('storage/' . $this->logo_path))) { if ($this->logo_path && Storage::disk('public')->exists($this->logo_path)) {
return asset('storage/' . $this->logo_path); return Storage::disk('public')->url($this->logo_path);
} }
if ($this->logo && file_exists(public_path('storage/' . $this->logo))) { if ($this->logo && Storage::disk('public')->exists($this->logo)) {
return asset('storage/' . $this->logo); return Storage::disk('public')->url($this->logo);
} }
return null; return null;
} }
public function getLogoSmallPath(): ?string public function getLogoSmallPath(): ?string
{ {
if ($this->logo_small_path && file_exists(public_path('storage/' . $this->logo_small_path))) { if ($this->logo_small_path && Storage::disk('public')->exists($this->logo_small_path)) {
return asset('storage/' . $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))) { if ($this->logo_small && Storage::disk('public')->exists($this->logo_small)) {
return asset('storage/' . $this->logo_small); return Storage::disk('public')->url($this->logo_small);
} }
return null; 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),
};
}
}
+14 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -9,9 +10,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Documento extends Model class Documento extends Model
{ {
use HasTagsLight;
protected $table = 'documenti'; protected $table = 'documenti';
protected $fillable = [ protected $fillable = [
'tenant_id', 'user_id', 'nome_file', 'file_path', 'tipo', 'tipologia', 'tenant_id', 'user_id', 'cartella_id', 'repository_id', 'storage_disk',
'nome_file', 'file_path', 'tipo', 'tipologia',
'visibilita', 'visibilita_target_id', 'visibilita_target_type', 'visibilita', 'visibilita_target_id', 'visibilita_target_type',
'mime_type', 'dimensione', 'note' 'mime_type', 'dimensione', 'note'
]; ];
@@ -21,6 +24,16 @@ class Documento extends Model
return $this->belongsTo(Tenant::class); return $this->belongsTo(Tenant::class);
} }
public function cartella(): BelongsTo
{
return $this->belongsTo(DocumentoCartella::class, 'cartella_id');
}
public function repository(): BelongsTo
{
return $this->belongsTo(StorageRepository::class, 'repository_id');
}
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class DocumentoCartella extends Model
{
protected $table = 'documenti_cartelle';
protected $fillable = ['parent_id', 'nome'];
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('nome');
}
public function documenti(): HasMany
{
return $this->hasMany(Documento::class, 'cartella_id');
}
public function ancestors(): array
{
$ancestors = [];
$current = $this;
while ($current->parent) {
array_unshift($ancestors, $current->parent);
$current = $current->parent;
}
return $ancestors;
}
public function fullPath(): string
{
$parts = collect($this->ancestors())->push($this)->pluck('nome')->toArray();
return '/' . implode('/', $parts);
}
}
+3
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Evento extends Model class Evento extends Model
{ {
use HasTagsLight;
protected $table = 'eventi'; protected $table = 'eventi';
protected $fillable = [ protected $fillable = [
@@ -30,6 +32,7 @@ class Evento extends Model
'is_incontro_gruppo', 'is_incontro_gruppo',
'luogo_indirizzo', 'luogo_indirizzo',
'luogo_url_maps', 'luogo_url_maps',
'uid_esterno',
]; ];
protected $casts = [ protected $casts = [
+2
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Gruppo extends Model class Gruppo extends Model
{ {
use HasTagsLight;
protected $table = 'gruppi'; protected $table = 'gruppi';
protected $fillable = [ protected $fillable = [
'tenant_id', 'parent_id', 'diocesi_id', 'responsabile_ids', 'tenant_id', 'parent_id', 'diocesi_id', 'responsabile_ids',
+2
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
class Individuo extends Model class Individuo extends Model
{ {
use HasTagsLight;
protected $table = 'individui'; protected $table = 'individui';
protected $fillable = [ protected $fillable = [
'tenant_id', 'codice_id', 'cognome', 'nome', 'data_nascita', '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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Traits\HasTagsLight;
class MailingList extends Model class MailingList extends Model
{ {
use HasTagsLight;
protected $table = 'mailing_lists'; protected $table = 'mailing_lists';
protected $fillable = ['tenant_id', 'user_id', 'nome', 'descrizione', 'attiva']; protected $fillable = ['tenant_id', 'user_id', 'nome', 'descrizione', 'attiva'];
+3 -1
View File
@@ -10,11 +10,13 @@ class MailingMessaggio extends Model
protected $table = 'mailing_messaggi'; protected $table = 'mailing_messaggi';
protected $fillable = [ protected $fillable = [
'tenant_id', 'mailing_list_id', 'user_id', 'oggetto', 'corpo', 'tenant_id', 'mailing_list_id', 'user_id', 'oggetto', 'corpo',
'canale', 'stato', 'inviato_al', 'totale_destinatari', 'invii_ok', 'invii_falliti' 'canale', 'stato', 'inviato_al', 'totale_destinatari', 'invii_ok', 'invii_falliti',
'log_falliti', 'mittente_nome', 'mittente_email',
]; ];
protected $casts = [ protected $casts = [
'inviato_al' => 'datetime', 'inviato_al' => 'datetime',
'log_falliti' => 'array',
]; ];
public function tenant(): BelongsTo public function tenant(): BelongsTo
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Crypt;
class SenderAccount extends Model
{
protected $table = 'sender_accounts';
protected $fillable = [
'email_address',
'email_name',
'smtp_host',
'smtp_port',
'smtp_encryption',
'smtp_username',
'smtp_password',
'reply_to',
'verify_email',
'note',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
'smtp_port' => 'integer',
];
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function getDecryptedPassword(): string
{
if (empty($this->smtp_password)) {
return '';
}
try {
return Crypt::decryptString($this->smtp_password);
} catch (\Exception $e) {
return $this->smtp_password;
}
}
public function sanitizeName(): string
{
$name = $this->email_name ?? '';
if (filter_var($name, FILTER_VALIDATE_EMAIL)) {
return 'Glastree';
}
$name = preg_replace('/[^\p{L}\p{N}\s]/u', '', $name);
$name = trim($name);
return empty($name) ? 'Glastree' : $name;
}
public function getReplyTo(): ?string
{
if ($this->reply_to) {
return $this->reply_to;
}
if ($this->verify_email) {
return $this->verify_email;
}
return null;
}
public function buildDsn(): string
{
$password = $this->getDecryptedPassword();
$username = $this->smtp_username ?? $this->email_address;
$encryption = $this->smtp_encryption ?? 'tls';
$scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp';
return sprintf(
'%s://%s:%s@%s:%d?encryption=%s',
$scheme,
rawurlencode($username),
rawurlencode($password),
$this->smtp_host ?? 'smtp.gmail.com',
$this->smtp_port ?? 587,
$encryption
);
}
public function buildSymfonyEmail(string $to, string $subject, string $body): \Symfony\Component\Mime\Email
{
$fromName = $this->sanitizeName();
$fromAddress = \Symfony\Component\Mime\Address::create($this->email_address, $fromName);
$email = (new \Symfony\Component\Mime\Email())
->from($fromAddress)
->to($to)
->subject($subject)
->text($body);
$replyTo = $this->getReplyTo();
if ($replyTo) {
$email->replyTo($replyTo);
}
return $email;
}
public function sendEmail(string $to, string $subject, string $body, array $attachmentPaths = []): void
{
$email = $this->buildSymfonyEmail($to, $subject, $body);
foreach ($attachmentPaths as $path) {
$email->attachFromPath($path);
}
$transport = \Symfony\Component\Mailer\Transport::fromDsn($this->buildDsn());
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
$mailer->send($email);
}
public function sendReport(string $subject, string $body): bool
{
if (empty($this->verify_email)) {
return false;
}
try {
$this->sendEmail($this->verify_email, $subject, $body);
return true;
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Sender report email failed', [
'sender' => $this->email_address,
'error' => $e->getMessage(),
]);
return false;
}
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class StorageRepository extends Model
{
protected $table = 'storage_repositories';
protected $fillable = [
'nome',
'tipo',
'config',
'is_active',
'ordine',
];
protected $casts = [
'config' => 'array',
'is_active' => 'boolean',
'ordine' => 'integer',
];
public function documenti()
{
return $this->hasMany(Documento::class, 'repository_id');
}
public function getDecryptedConfig(): array
{
$config = $this->config;
if (isset($config['password'])) {
try {
$config['password'] = Crypt::decryptString($config['password']);
} catch (\Exception) {
}
}
if (isset($config['refresh_token'])) {
try {
$config['refresh_token'] = Crypt::decryptString($config['refresh_token']);
} catch (\Exception) {
}
}
if (isset($config['client_secret'])) {
try {
$config['client_secret'] = Crypt::decryptString($config['client_secret']);
} catch (\Exception) {
}
}
return $config;
}
public function scopeAttivi($query)
{
return $query->where('is_active', true)->orderBy('ordine');
}
public static function opzioniTipo(): array
{
return ['webdav', 'google_drive'];
}
public static function etichettaTipo(string $tipo): string
{
return match ($tipo) {
'webdav' => 'WebDAV',
'google_drive' => 'Google Drive',
default => ucfirst($tipo),
};
}
public static function iconaTipo(string $tipo): string
{
return match ($tipo) {
'webdav' => 'fa-server',
'google_drive' => 'fa-google-drive',
default => 'fa-database',
};
}
}
+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();
}
}
+69 -3
View File
@@ -7,14 +7,22 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
#[Fillable(['name', 'email', 'password', 'tenant_id', 'is_admin', 'permissions', 'role_preset_id', 'status'])] #[Fillable(['name', 'email', 'password', 'tenant_id', 'is_admin', 'permissions', 'role_preset_id', 'status'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable implements CanResetPasswordContract
{ {
use HasFactory, Notifiable; use HasFactory, Notifiable, CanResetPassword;
protected $fillable = ['name', 'email', 'password', 'tenant_id', 'is_admin', 'permissions', 'role_preset_id', 'status']; protected $fillable = ['name', 'email', 'password', 'tenant_id', 'is_admin', 'permissions', 'role_preset_id', 'status'];
@@ -33,7 +41,7 @@ class User extends Authenticatable
public const STATUS_SUSPENDED = 'suspended'; public const STATUS_SUSPENDED = 'suspended';
public const STATUS_PENDING = 'pending'; public const STATUS_PENDING = 'pending';
public const MODULES = ['individui', 'gruppi', 'eventi', 'documenti', 'mailing', 'viste', 'report']; public const MODULES = ['individui', 'gruppi', 'eventi', 'documenti', 'mailing', 'viste', 'report', 'settings'];
public const LEVEL_NONE = 0; public const LEVEL_NONE = 0;
public const LEVEL_READ = 1; public const LEVEL_READ = 1;
public const LEVEL_FULL = 2; public const LEVEL_FULL = 2;
@@ -156,4 +164,62 @@ class User extends Authenticatable
$this->permissions = $preset->permissions; $this->permissions = $preset->permissions;
return $this; return $this;
} }
public function sendPasswordResetNotification($token): void
{
$settings = EmailSetting::getActive();
if (!$settings || empty($settings->smtp_host)) {
Log::warning('Impossibile inviare reset password: SMTP non configurato');
return;
}
try {
$smtpUsername = $settings->smtp_username ?: $settings->email_address;
$smtpPassword = $settings->smtp_password
? Crypt::decryptString($settings->smtp_password)
: $settings->getDecryptedPassword();
$encryption = $settings->smtp_encryption ?? 'tls';
$scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp';
$dsn = sprintf(
'%s://%s:%s@%s:%d?encryption=%s',
$scheme,
rawurlencode($smtpUsername),
rawurlencode($smtpPassword),
$settings->smtp_host,
$settings->smtp_port ?? 587,
$encryption
);
$transport = Transport::fromDsn($dsn);
$mailer = new Mailer($transport);
$resetUrl = url('/password/reset/' . $token);
$fromName = $settings->email_name ?? config('app.name');
$fromName = preg_replace('/[^\p{L}\p{N}\s]/u', '', $fromName);
$fromName = trim($fromName) ?: config('app.name');
$email = (new Email())
->from(new Address($settings->email_address, $fromName))
->to($this->email)
->subject('Reset della password - ' . config('app.name'))
->html(view('auth.emails.reset-password', [
'user' => $this,
'token' => $token,
'resetUrl' => $resetUrl,
'appName' => config('app.name'),
])->render());
$mailer->send($email);
Log::info('Password reset email sent', ['email' => $this->email]);
} catch (\Exception $e) {
Log::error('Failed to send password reset email', [
'email' => $this->email,
'error' => $e->getMessage(),
]);
}
}
} }
+4 -1
View File
@@ -10,13 +10,16 @@ class VistaReport extends Model
protected $table = 'viste_report'; protected $table = 'viste_report';
protected $fillable = [ protected $fillable = [
'user_id', 'nome', 'tipo', 'colonne_visibili', 'user_id', 'nome', 'tipo', 'colonne_visibili',
'colonne_ordinamento', 'filtri', 'ricerca' 'colonne_ordinamento', 'filtri', 'ricerca', 'is_default',
'colonne_larghezze', 'colonne_ordine',
]; ];
protected $casts = [ protected $casts = [
'colonne_visibili' => 'array', 'colonne_visibili' => 'array',
'colonne_ordinamento' => 'array', 'colonne_ordinamento' => 'array',
'filtri' => 'array', 'filtri' => 'array',
'colonne_larghezze' => 'array',
'colonne_ordine' => 'array',
]; ];
public function user(): BelongsTo public function user(): BelongsTo
+297
View File
@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\AppSetting;
use App\Models\Notifica;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use ZipArchive;
class BackupService
{
private string $backupDir;
private string $tempDir;
public function __construct()
{
$basePath = AppSetting::getSetting('backup_path', 'backups');
$this->backupDir = storage_path('app/' . ltrim($basePath, '/'));
$this->tempDir = storage_path('app/backup-temp');
}
public function getBackupDir(): string
{
return $this->backupDir;
}
public function list(): array
{
if (!is_dir($this->backupDir)) {
return [];
}
$files = File::files($this->backupDir);
$backups = [];
foreach ($files as $file) {
if ($file->getExtension() !== 'zip') {
continue;
}
$manifest = $this->readManifest($file->getPathname());
$backups[] = [
'filename' => $file->getFilename(),
'pathname' => $file->getPathname(),
'size' => $file->getSize(),
'size_formatted' => $this->formatBytes($file->getSize()),
'last_modified' => $file->getMTime(),
'manifest' => $manifest,
];
}
usort($backups, fn(array $a, array $b) => $b['last_modified'] - $a['last_modified']);
return $backups;
}
public function run(): array
{
$result = ['success' => true, 'message' => '', 'filename' => ''];
try {
$this->cleanTemp();
File::ensureDirectoryExists($this->tempDir, 0755, true);
File::ensureDirectoryExists($this->backupDir, 0755, true);
$timestamp = now()->format('Y-m-d_H-i-s');
$appName = str_replace(' ', '_', AppSetting::getAppName() ?? 'app');
$filename = $appName . '_' . $timestamp . '.zip';
$filepath = $this->backupDir . '/' . $filename;
$steps = [];
$steps[] = $this->backupDatabase();
$steps[] = $this->backupEnv();
$steps[] = $this->backupFiles();
$this->writeManifest($timestamp);
$zipResult = $this->createZip($filepath);
if (!$zipResult) {
throw new RuntimeException('Errore durante la creazione del file ZIP.');
}
$this->cleanTemp();
$size = file_exists($filepath) ? filesize($filepath) : 0;
Log::info("Backup completato: {$filename} (" . $this->formatBytes($size) . ")");
$this->cleanupOldBackups();
return [
'success' => true,
'message' => 'Backup completato con successo.',
'filename' => $filename,
'size' => $size,
'size_formatted' => $this->formatBytes($size),
];
} catch (\Exception $e) {
$this->cleanTemp();
Log::error('Backup fallito: ' . $e->getMessage());
return [
'success' => false,
'message' => 'Backup fallito: ' . $e->getMessage(),
'filename' => '',
];
}
}
public function download(string $filename): ?string
{
$filepath = $this->backupDir . '/' . basename($filename);
return file_exists($filepath) ? $filepath : null;
}
public function delete(string $filename): bool
{
$filepath = $this->backupDir . '/' . basename($filename);
if (file_exists($filepath)) {
unlink($filepath);
Log::info("Backup eliminato: {$filename}");
return true;
}
return false;
}
public function getConfig(): array
{
return [
'backup_path' => AppSetting::getSetting('backup_path', 'backups'),
'backup_retention_days' => (int) AppSetting::getSetting('backup_retention_days', 30),
'backup_include_files' => (bool) AppSetting::getSetting('backup_include_files', true),
'backup_include_env' => (bool) AppSetting::getSetting('backup_include_env', true),
];
}
public function saveConfig(array $data): void
{
$settings = AppSetting::first() ?? new AppSetting();
$settings->backup_path = trim($data['backup_path'] ?? 'backups', '/');
$settings->backup_retention_days = max(1, (int) ($data['backup_retention_days'] ?? 30));
$settings->backup_include_files = !empty($data['backup_include_files']);
$settings->backup_include_env = !empty($data['backup_include_env']);
$settings->save();
}
private function backupDatabase(): string
{
$dbName = config('database.connections.mysql.database');
$dbUser = config('database.connections.mysql.username');
$dbPass = config('database.connections.mysql.password');
$dbHost = config('database.connections.mysql.host');
$dbPort = config('database.connections.mysql.port');
$sqlFile = $this->tempDir . '/database.sql';
$command = sprintf(
'mysqldump --host=%s --port=%s --user=%s --password=%s --routines --events --single-transaction --quick %s > %s 2>&1',
escapeshellarg($dbHost),
escapeshellarg($dbPort),
escapeshellarg($dbUser),
escapeshellarg($dbPass),
escapeshellarg($dbName),
escapeshellarg($sqlFile)
);
$output = null;
$exitCode = null;
exec($command, $output, $exitCode);
if ($exitCode !== 0 || !file_exists($sqlFile) || filesize($sqlFile) === 0) {
throw new RuntimeException('mysqldump fallito: ' . implode("\n", (array) $output));
}
return 'database.sql (' . $this->formatBytes(filesize($sqlFile)) . ')';
}
private function backupEnv(): string
{
if (!AppSetting::getSetting('backup_include_env', true)) {
return '.env (escluso)';
}
$envPath = base_path('.env');
$envKeyPath = base_path('.env.key');
if (file_exists($envPath)) {
copy($envPath, $this->tempDir . '/.env');
}
if (file_exists($envKeyPath)) {
copy($envKeyPath, $this->tempDir . '/.env.key');
}
return '.env copiato';
}
private function backupFiles(): string
{
if (!AppSetting::getSetting('backup_include_files', true)) {
return 'files (esclusi)';
}
$filesDir = AppSetting::getDocumentiStorageAbsolutePath();
$targetDir = $this->tempDir . '/files';
if (!is_dir($filesDir)) {
return 'files (cartella non trovata)';
}
File::ensureDirectoryExists($targetDir, 0755, true);
File::copyDirectory($filesDir, $targetDir);
return 'files copiati';
}
private function writeManifest(string $timestamp): void
{
$manifest = [
'created_at' => $timestamp,
'app_version' => AppSetting::getAppVersion() ?? 'unknown',
'app_name' => AppSetting::getAppName() ?? 'Glastree',
'db_driver' => config('database.default'),
'db_name' => config('database.connections.mysql.database'),
'php_version' => PHP_VERSION,
'laravel_version' => app()->version(),
];
File::put($this->tempDir . '/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT));
}
private function readManifest(string $zipPath): ?array
{
$zip = new ZipArchive();
if ($zip->open($zipPath) === true) {
$manifestContent = $zip->getFromName('manifest.json');
$zip->close();
if ($manifestContent !== false) {
return json_decode($manifestContent, true);
}
}
return null;
}
private function createZip(string $filepath): bool
{
$zip = new ZipArchive();
if ($zip->open($filepath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
return false;
}
$files = File::allFiles($this->tempDir);
foreach ($files as $file) {
$relativePath = substr($file->getPathname(), strlen($this->tempDir) + 1);
$zip->addFile($file->getPathname(), $relativePath);
}
return $zip->close();
}
private function cleanTemp(): void
{
if (is_dir($this->tempDir)) {
File::deleteDirectory($this->tempDir);
}
}
private function cleanupOldBackups(): void
{
$retentionDays = (int) AppSetting::getSetting('backup_retention_days', 30);
$cutoff = now()->subDays($retentionDays)->timestamp;
foreach (File::files($this->backupDir) as $file) {
if ($file->getExtension() === 'zip' && $file->getMTime() < $cutoff) {
unlink($file->getPathname());
Log::info("Backup eliminato per retention: " . $file->getFilename());
}
}
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
}
+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;
}
}
+212
View File
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Evento;
use Carbon\Carbon;
class IcsExportService
{
private const DATETIME_FORMAT = 'Ymd\THis';
private const DATE_FORMAT = 'Ymd';
private const TIMEZONE = 'Europe/Rome';
private const DAY_MAP = [
0 => 'SU',
1 => 'MO',
2 => 'TU',
3 => 'WE',
4 => 'TH',
5 => 'FR',
6 => 'SA',
];
public function generateSingle(Evento $evento): string
{
return $this->generateCollection(collect([$evento]));
}
public function generateCollection(iterable $eventi): string
{
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Glastree//Eventi//IT',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:' . config('app.name') . ' - Eventi',
];
foreach ($eventi as $evento) {
array_push($lines, ...$this->buildVEvent($evento));
}
$lines[] = 'END:VCALENDAR';
return $this->fold(implode("\r\n", $lines));
}
private function buildVEvent(Evento $evento): array
{
$uid = 'evento-' . $evento->id . '@' . preg_replace('/[^a-z0-9]/', '', strtolower(config('app.name')));
$lines = [
'BEGIN:VEVENT',
'UID:' . $uid,
'DTSTAMP:' . now()->format(self::DATETIME_FORMAT) . 'Z',
];
if ($evento->isSingolo() && $evento->data_specifica) {
$date = $evento->data_specifica instanceof Carbon
? $evento->data_specifica
: Carbon::parse($evento->data_specifica);
if ($evento->ora_inizio) {
$start = $date->copy()->setTimeFrom($evento->ora_inizio instanceof Carbon ? $evento->ora_inizio : Carbon::parse($evento->ora_inizio));
$durata = $evento->durata_minuti ?? 60;
$end = $start->copy()->addMinutes((int) $durata);
$lines[] = 'DTSTART;TZID=' . self::TIMEZONE . ':' . $start->format(self::DATETIME_FORMAT);
$lines[] = 'DTEND;TZID=' . self::TIMEZONE . ':' . $end->format(self::DATETIME_FORMAT);
} else {
$lines[] = 'DTSTART;VALUE=DATE:' . $date->format(self::DATE_FORMAT);
$lines[] = 'DTEND;VALUE=DATE:' . $date->copy()->addDay()->format(self::DATE_FORMAT);
}
} else {
$rrule = $this->buildRRule($evento);
if ($rrule) {
$lines[] = $rrule;
}
if ($evento->ora_inizio) {
$time = $evento->ora_inizio instanceof Carbon ? $evento->ora_inizio : Carbon::parse($evento->ora_inizio);
$lines[] = 'DTSTART;TZID=' . self::TIMEZONE . ':' . $time->format('His');
$durata = $evento->durata_minuti ?? 60;
$endTime = $time->copy()->addMinutes((int) $durata);
$lines[] = 'DTEND;TZID=' . self::TIMEZONE . ':' . $endTime->format('His');
}
}
$lines[] = 'SUMMARY:' . $this->escapeText($evento->nome_evento);
if ($evento->descrizione_evento || $evento->descrizione) {
$desc = $evento->descrizione_evento
? ($evento->descrizione ? $evento->descrizione_evento . '\n\n' . $evento->descrizione : $evento->descrizione_evento)
: $evento->descrizione;
$lines[] = 'DESCRIPTION:' . $this->escapeText($desc);
}
if ($evento->luogo_indirizzo) {
$lines[] = 'LOCATION:' . $this->escapeText($evento->luogo_indirizzo);
}
$lines[] = 'END:VEVENT';
return $lines;
}
private function buildRRule(Evento $evento): ?string
{
return match ($evento->tipo_recorrenza) {
'settimanale' => $this->rruleSettimanale($evento),
'mensile' => $this->rruleMensile($evento),
'annuale' => $this->rruleAnnuale($evento),
'altro' => $this->rruleAltro($evento),
default => null,
};
}
private function rruleSettimanale(Evento $evento): ?string
{
if ($evento->giorno_settimana === null) {
return null;
}
return 'RRULE:FREQ=WEEKLY;BYDAY=' . (self::DAY_MAP[(int) $evento->giorno_settimana] ?? 'MO');
}
private function rruleMensile(Evento $evento): ?string
{
if (!$evento->occorrenza_mese) {
return null;
}
$parts = explode(',', $evento->occorrenza_mese);
if (count($parts) !== 2) {
return null;
}
$occorrenza = (int) $parts[0];
$giorno = (int) $parts[1];
$byDay = self::DAY_MAP[$giorno] ?? 'MO';
$rrule = 'RRULE:FREQ=MONTHLY;BYSETPOS=' . $occorrenza . ';BYDAY=' . $byDay;
if ($evento->mesi_recorrenza) {
$mesi = explode(',', $evento->mesi_recorrenza);
$rrule .= ';BYMONTH=' . implode(',', $mesi);
}
return $rrule;
}
private function rruleAnnuale(Evento $evento): ?string
{
if (!$evento->mese_annuale) {
return null;
}
$rrule = 'RRULE:FREQ=YEARLY;BYMONTH=' . $evento->mese_annuale;
$giorno = (int) ($evento->giorno_mese ?? 1);
$rrule .= ';BYMONTHDAY=' . $giorno;
return $rrule;
}
private function rruleAltro(Evento $evento): ?string
{
return $this->rruleSettimanale($evento);
}
private function escapeText(?string $text): string
{
if ($text === null) {
return '';
}
$text = str_replace(
['\\', ';', ',', "\n"],
['\\\\', '\\;', '\\,', '\\n'],
$text
);
return $text;
}
private function fold(string $ics): string
{
$lines = explode("\r\n", $ics);
$folded = [];
foreach ($lines as $line) {
while (mb_strlen($line) > 75) {
$folded[] = mb_substr($line, 0, 75);
$line = ' ' . mb_substr($line, 75);
}
$folded[] = $line;
}
return implode("\r\n", $folded);
}
public function getFilename(Evento $evento): string
{
$name = str_replace(['/', '\\', ' '], '_', $evento->nome_evento);
return $name . '.ics';
}
public function getCollectionFilename(): string
{
return 'eventi_' . now()->format('Ymd_His') . '.ics';
}
}
+364
View File
@@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\StorageRepository;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use League\Flysystem\Filesystem;
use League\Flysystem\WebDAV\WebDAVAdapter;
use As247\Flysystem\GoogleDrive\GoogleDriveAdapter;
use Sabre\DAV\Client as WebDAVClient;
class StorageRepositoryService
{
public function buildFilesystem(StorageRepository $repo): ?Filesystem
{
try {
$config = $repo->getDecryptedConfig();
// Log only the keys of the config (avoid sensitive values)
try {
Log::debug("StorageRepository: config keys for repo #{$repo->id}: " . json_encode(array_keys($config)));
} catch (\Exception) {
// ignore logging errors
}
return match ($repo->tipo) {
'webdav' => $this->buildWebDAV($config),
'google_drive' => $this->buildGoogleDrive($config),
default => null,
};
} catch (\Exception $e) {
Log::error("StorageRepository: failed to build filesystem for repo #{$repo->id} ({$repo->nome}): " . $e->getMessage());
return null;
}
}
public function testConnection(StorageRepository $repo): array
{
try {
$config = $repo->getDecryptedConfig();
if ($repo->tipo === 'google_drive') {
if (empty($config['client_id']) || empty($config['client_secret']) || empty($config['refresh_token'])) {
return ['success' => false, 'message' => 'Configurazione incompleta: client_id, client_secret e refresh_token sono necessari per Google Drive.'];
}
$apiError = $this->checkGoogleDriveApiStatus($config);
if ($apiError) {
return ['success' => false, 'message' => $apiError];
}
}
$filesystem = $this->buildFilesystem($repo);
if (!$filesystem) {
return ['success' => false, 'message' => 'Impossibile costruire il filesystem per questo tipo di repository.'];
}
$rootPath = $this->normalizePath('/');
$contents = $filesystem->listContents($rootPath, false)->toArray();
return ['success' => true, 'message' => 'Connessione riuscita. ' . count($contents) . ' elementi trovati nella root.'];
} catch (\Exception $e) {
Log::error("StorageRepository: test connection failed for repo #{$repo->id} ({$repo->nome}): " . $e->getMessage());
return ['success' => false, 'message' => 'Errore di connessione: ' . $e->getMessage()];
}
}
private function checkGoogleDriveApiStatus(array $config): ?string
{
try {
$client = new \Google_Client();
$client->setClientId($config['client_id']);
$client->setClientSecret($config['client_secret']);
$client->addScope(\Google_Service_Drive::DRIVE);
$token = $client->refreshToken($config['refresh_token']);
$client->setAccessToken($token);
$service = new \Google_Service_Drive($client);
$rootId = $config['root_folder_id'] ?? 'root';
$service->files->get($rootId, ['fields' => 'id']);
return null;
} catch (\Google\Service\Exception $e) {
$errors = $e->getErrors();
$reason = $errors[0]['reason'] ?? '';
$message = $errors[0]['message'] ?? $e->getMessage();
if ($reason === 'accessNotConfigured' || str_contains($message, 'Drive API has not been used')) {
$projectNumber = explode('-', $config['client_id'] ?? '')[0] ?? '';
return 'Google Drive API non abilitata. Vai su https://console.developers.google.com/apis/api/drive.googleapis.com/overview?project=' . $projectNumber . ' e clicca "Enable".';
}
if ($e->getCode() === 404) {
return 'Cartella root non trovata (ID: ' . ($config['root_folder_id'] ?? 'root') . '). Verifica che il folder ID sia corretto.';
}
return 'Errore API Google Drive: ' . $message;
} catch (\Exception $e) {
return 'Errore di connessione Google Drive: ' . $e->getMessage();
}
}
public function listContents(StorageRepository $repo, string $path = '/'): array
{
try {
$config = $repo->getDecryptedConfig();
if ($repo->tipo === 'google_drive') {
if (empty($config['client_id']) || empty($config['client_secret']) || empty($config['refresh_token'])) {
Log::warning("StorageRepository: listContents failed for repo #{$repo->id} ({$repo->nome}): credenziali Google Drive mancanti");
return [];
}
}
$filesystem = $this->buildFilesystem($repo);
if (!$filesystem) {
return [];
}
$normalizedPath = $this->normalizePath($path);
$items = $filesystem->listContents($normalizedPath, false)->toArray();
$result = [];
foreach ($items as $item) {
$path = method_exists($item, 'path') ? $item->path() : (is_array($item) && isset($item['path']) ? $item['path'] : null);
$type = method_exists($item, 'type') ? $item->type() : (is_array($item) && isset($item['type']) ? $item['type'] : null);
$basename = $path ? basename($path) : '';
$lastModified = method_exists($item, 'lastModified') ? $item->lastModified() : (is_array($item) && isset($item['lastmodified']) ? $item['lastmodified'] : null);
$fileSize = method_exists($item, 'fileSize') ? $item->fileSize() : (is_array($item) && isset($item['filesize']) ? $item['filesize'] : null);
$mimeType = method_exists($item, 'mimeType') ? $item->mimeType() : (is_array($item) && isset($item['mimetype']) ? $item['mimetype'] : null);
$result[] = [
'path' => $path,
'type' => $type,
'basename' => $basename,
'lastModified' => $lastModified,
'fileSize' => $fileSize,
'mimeType' => $mimeType,
];
}
usort($result, fn($a, $b) => ($a['type'] === 'dir' && $b['type'] !== 'dir') ? -1 : (($b['type'] === 'dir' && $a['type'] !== 'dir') ? 1 : strcasecmp($a['basename'], $b['basename'])));
Log::debug("StorageRepository: listContents repo #{$repo->id} tipo={$repo->tipo} path='{$normalizedPath}' results=" . count($result));
return $result;
} catch (\Exception $e) {
Log::error("StorageRepository: listContents failed for repo #{$repo->id} path '{$path}': " . $e->getMessage());
return [];
}
}
public function encryptSensitiveConfig(array $config, string $tipo, array $existingConfig = []): array
{
$sensitiveFields = match ($tipo) {
'webdav' => ['password'],
'google_drive' => ['client_secret', 'refresh_token'],
default => [],
};
foreach ($sensitiveFields as $field) {
if (!empty($config[$field]) && !$this->isEncrypted($config[$field])) {
$config[$field] = Crypt::encryptString($config[$field]);
} elseif (empty($config[$field]) && !empty($existingConfig[$field])) {
$config[$field] = $existingConfig[$field];
}
}
return $config;
}
public function normalizePath(string $path): string
{
$path = trim($path);
if ($path === '' || $path === '\\') {
return '';
}
if ($path === '/') {
return '';
}
return ltrim($path, '/\\');
}
private function isEncrypted(string $value): bool
{
return str_starts_with($value, 'ey') || str_contains($value, ':');
}
private function buildWebDAV(array $config): ?Filesystem
{
$fullBaseUri = rtrim($config['base_uri'] ?? '', '/') . '/';
$parsedUrl = parse_url($fullBaseUri);
$schemeHost = ($parsedUrl['scheme'] ?? 'https') . '://' . ($parsedUrl['host'] ?? 'localhost');
$basePath = rawurldecode($parsedUrl['path'] ?? '/');
$root = '/' . trim($config['root'] ?? '/', '/') . '/';
$prefix = rtrim($basePath, '/') . $root;
$authType = match ($config['auth_type'] ?? 'basic') {
'basic' => WebDAVClient::AUTH_BASIC,
'digest' => WebDAVClient::AUTH_DIGEST,
'ntlm' => WebDAVClient::AUTH_NTLM,
default => WebDAVClient::AUTH_BASIC,
};
$client = new WebDAVClient([
'baseUri' => $schemeHost . '/',
'userName' => $config['username'] ?? '',
'password' => $config['password'] ?? '',
'authType' => $authType,
]);
$adapter = new WebDAVAdapter($client, $prefix);
return new Filesystem($adapter);
}
private function buildGoogleDrive(array $config): ?Filesystem
{
if (empty($config['client_id']) || empty($config['client_secret']) || empty($config['refresh_token'])) {
throw new \InvalidArgumentException('Google Drive richiede client_id, client_secret e refresh_token per l\'autenticazione.');
}
$client = new \Google_Client();
$client->setClientId($config['client_id']);
$client->setClientSecret($config['client_secret']);
$client->addScope(\Google_Service_Drive::DRIVE);
// Refresh token per ottenere un access token valido
$token = $client->refreshToken($config['refresh_token']);
$client->setAccessToken($token);
$service = new \Google_Service_Drive($client);
$rootFolderId = $config['root_folder_id'] ?? 'root';
$adapter = new GoogleDriveAdapter($service, $rootFolderId);
return new Filesystem($adapter);
}
public function readGoogleDriveFile(StorageRepository $repo, string $normalizedPath, string $basename): array
{
$config = $repo->getDecryptedConfig();
$client = new \Google_Client();
$client->setClientId($config['client_id']);
$client->setClientSecret($config['client_secret']);
$client->addScope(\Google_Service_Drive::DRIVE);
$token = $client->refreshToken($config['refresh_token']);
$client->setAccessToken($token);
$service = new \Google_Service_Drive($client);
$rootFolderId = $config['root_folder_id'] ?? 'root';
$fileId = $this->findGoogleDriveFileId($service, $rootFolderId, $normalizedPath);
$file = $service->files->get($fileId, ['fields' => 'id,name,mimeType,size']);
$mimeType = $file->getMimeType();
if ($mimeType && str_starts_with($mimeType, 'application/vnd.google-apps.')) {
return $this->exportGoogleDriveDoc($service, $fileId, $mimeType, $basename);
}
$response = $service->files->get($fileId, ['alt' => 'media']);
$body = $response->getBody();
$stream = $body instanceof \Psr\Http\Message\StreamInterface ? $body->detach() : null;
if (!$stream) {
$stream = fopen('php://temp', 'r+');
fwrite($stream, (string) $body);
rewind($stream);
}
$fileSize = $file->getSize();
return [
'stream' => $stream,
'mimeType' => $mimeType,
'basename' => $basename,
'fileSize' => $fileSize !== null ? (int) $fileSize : null,
];
}
private function exportGoogleDriveDoc(\Google_Service_Drive $service, string $fileId, string $googleMimeType, string $originalBasename): array
{
$exportMap = [
'application/vnd.google-apps.document' => [
'mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'ext' => 'docx',
],
'application/vnd.google-apps.spreadsheet' => [
'mime' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ext' => 'xlsx',
],
'application/vnd.google-apps.presentation' => [
'mime' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'ext' => 'pptx',
],
'application/vnd.google-apps.drawing' => [
'mime' => 'image/png',
'ext' => 'png',
],
];
$format = $exportMap[$googleMimeType] ?? ['mime' => 'application/pdf', 'ext' => 'pdf'];
$response = $service->files->export($fileId, $format['mime'], ['alt' => 'media']);
$body = $response->getBody();
$stream = $body instanceof \Psr\Http\Message\StreamInterface ? $body->detach() : null;
if (!$stream) {
$stream = fopen('php://temp', 'r+');
fwrite($stream, (string) $body);
rewind($stream);
}
$nameWithoutExt = pathinfo($originalBasename, PATHINFO_FILENAME);
$newBasename = $nameWithoutExt . '.' . $format['ext'];
Log::info("StorageRepository: exported Google Drive file {$fileId} from {$googleMimeType} to {$format['mime']} as {$newBasename}");
return [
'stream' => $stream,
'mimeType' => $format['mime'],
'basename' => $newBasename,
'fileSize' => null,
];
}
private function findGoogleDriveFileId(\Google_Service_Drive $service, string $rootFolderId, string $path): string
{
$path = trim($path, '/');
if ($path === '') {
return $rootFolderId;
}
$parts = explode('/', $path);
$currentParentId = $rootFolderId;
foreach ($parts as $name) {
$optParams = [
'q' => sprintf("trashed = false and '%s' in parents and name = '%s'",
str_replace("'", "\\'", $currentParentId),
str_replace("'", "\\'", $name)
),
'fields' => 'files(id, name, mimeType)',
'pageSize' => 1,
];
$results = $service->files->listFiles($optParams);
$files = $results->getFiles();
if (empty($files)) {
throw new \RuntimeException("Elemento non trovato su Google Drive: {$name} (path: {$path})");
}
$currentParentId = $files[0]->getId();
}
return $currentParentId;
}
}
+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"
+4
View File
@@ -7,9 +7,13 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"as247/flysystem-google-drive": "^3.0",
"directorytree/imapengine-laravel": "^1.2", "directorytree/imapengine-laravel": "^1.2",
"google/apiclient": "^2.19",
"laravel/framework": "^13.7", "laravel/framework": "^13.7",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"league/flysystem-webdav": "^3.31",
"sabre/vobject": "^4.6",
"spatie/laravel-permission": "^7.4", "spatie/laravel-permission": "^7.4",
"webklex/php-imap": "^6.2" "webklex/php-imap": "^6.2"
}, },
Generated
+888 -1
View File
@@ -4,8 +4,112 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "a0180596cd749c38b349eb76b18c390b", "content-hash": "c594ad5c5f9ff96dc8644231135f694e",
"packages": [ "packages": [
{
"name": "as247/cloud-storages",
"version": "v1.2.9",
"source": {
"type": "git",
"url": "https://github.com/as247/cloud-storages.git",
"reference": "6a8ef430f7f07a2e88ea6b7cfd32230da493c1f8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/as247/cloud-storages/zipball/6a8ef430f7f07a2e88ea6b7cfd32230da493c1f8",
"reference": "6a8ef430f7f07a2e88ea6b7cfd32230da493c1f8",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": ">=7.1"
},
"type": "library",
"autoload": {
"psr-4": {
"As247\\CloudStorages\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "As247",
"email": "as247@vui360.com",
"homepage": "http://as247.vui360.com"
}
],
"description": "Storages wrapper with path support for drive services: google drive, onedrive,...",
"homepage": "https://github.com/as247",
"keywords": [
"drive",
"google",
"google drive",
"one drive",
"php",
"storage"
],
"support": {
"issues": "https://github.com/as247/cloud-storages/issues",
"source": "https://github.com/as247/cloud-storages/tree/v1.2.9"
},
"time": "2024-03-01T10:56:22+00:00"
},
{
"name": "as247/flysystem-google-drive",
"version": "v3.0.2",
"source": {
"type": "git",
"url": "https://github.com/as247/flysystem-google-drive.git",
"reference": "7d2600c5e085cafbb49c9874d1efb6795d36cb2f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/as247/flysystem-google-drive/zipball/7d2600c5e085cafbb49c9874d1efb6795d36cb2f",
"reference": "7d2600c5e085cafbb49c9874d1efb6795d36cb2f",
"shasum": ""
},
"require": {
"as247/cloud-storages": "^1.2.4",
"ext-json": "*",
"google/apiclient": "^2.0",
"league/flysystem": "^3.23",
"php": ">=7.1"
},
"type": "library",
"autoload": {
"psr-4": {
"As247\\Flysystem\\GoogleDrive\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "As247",
"email": "as247@vui360.com",
"homepage": "http://as247.vui360.com"
}
],
"description": "Google Drive Adapter for Flysystem",
"homepage": "https://github.com/as247",
"keywords": [
"drive",
"google",
"google-drive",
"php",
"storage"
],
"support": {
"issues": "https://github.com/as247/flysystem-google-drive/issues",
"source": "https://github.com/as247/flysystem-google-drive/tree/v3.0.2"
},
"time": "2024-05-30T14:41:37+00:00"
},
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.14.8", "version": "0.14.8",
@@ -635,6 +739,70 @@
], ],
"time": "2025-03-06T22:45:56+00:00" "time": "2025-03-06T22:45:56+00:00"
}, },
{
"name": "firebase/php-jwt",
"version": "v7.0.5",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-jwt.git",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
},
"time": "2026-04-01T20:38:03+00:00"
},
{ {
"name": "fruitcake/php-cors", "name": "fruitcake/php-cors",
"version": "v1.4.0", "version": "v1.4.0",
@@ -706,6 +874,183 @@
], ],
"time": "2025-12-03T09:33:47+00:00" "time": "2025-12-03T09:33:47+00:00"
}, },
{
"name": "google/apiclient",
"version": "v2.19.3",
"source": {
"type": "git",
"url": "https://github.com/googleapis/google-api-php-client.git",
"reference": "a1f02761994fd9defb20f6f1449205fd66f450de"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/google-api-php-client/zipball/a1f02761994fd9defb20f6f1449205fd66f450de",
"reference": "a1f02761994fd9defb20f6f1449205fd66f450de",
"shasum": ""
},
"require": {
"firebase/php-jwt": "^6.0||^7.0",
"google/apiclient-services": "~0.350",
"google/auth": "^1.37",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.6",
"monolog/monolog": "^2.9||^3.0",
"php": "^8.1"
},
"require-dev": {
"cache/filesystem-adapter": "^1.1",
"composer/composer": "^2.9",
"phpcompatibility/php-compatibility": "^9.2",
"phpspec/prophecy-phpunit": "^2.1",
"phpunit/phpunit": "^9.6",
"squizlabs/php_codesniffer": "^3.8",
"symfony/css-selector": "~2.1",
"symfony/dom-crawler": "~2.1"
},
"suggest": {
"cache/filesystem-adapter": "For caching certs and tokens (using Google\\Client::setCache)"
},
"type": "library",
"extra": {
"component": {
"entry": "src/Client.php"
},
"branch-alias": {
"dev-main": "2.x-dev"
}
},
"autoload": {
"files": [
"src/aliases.php"
],
"psr-4": {
"Google\\": "src/"
},
"classmap": [
"src/aliases.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Client library for Google APIs",
"homepage": "http://developers.google.com/api-client-library/php",
"keywords": [
"google"
],
"support": {
"issues": "https://github.com/googleapis/google-api-php-client/issues",
"source": "https://github.com/googleapis/google-api-php-client/tree/v2.19.3"
},
"time": "2026-05-04T21:00:36+00:00"
},
{
"name": "google/apiclient-services",
"version": "v0.442.0",
"source": {
"type": "git",
"url": "https://github.com/googleapis/google-api-php-client-services.git",
"reference": "3afe7b5c9d1cebf2a5be018820ce62eeb906b81f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/3afe7b5c9d1cebf2a5be018820ce62eeb906b81f",
"reference": "3afe7b5c9d1cebf2a5be018820ce62eeb906b81f",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"phpunit/phpunit": "^9.6"
},
"type": "library",
"autoload": {
"files": [
"autoload.php"
],
"psr-4": {
"Google\\Service\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Client library for Google APIs",
"homepage": "http://developers.google.com/api-client-library/php",
"keywords": [
"google"
],
"support": {
"issues": "https://github.com/googleapis/google-api-php-client-services/issues",
"source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.442.0"
},
"time": "2026-05-25T01:34:24+00:00"
},
{
"name": "google/auth",
"version": "v1.50.1",
"source": {
"type": "git",
"url": "https://github.com/googleapis/google-auth-library-php.git",
"reference": "870c17ee3a1d73338d39a9ffa77a700ba77f5a83"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/870c17ee3a1d73338d39a9ffa77a700ba77f5a83",
"reference": "870c17ee3a1d73338d39a9ffa77a700ba77f5a83",
"shasum": ""
},
"require": {
"firebase/php-jwt": "^6.0||^7.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.4.5",
"php": "^8.1",
"psr/cache": "^2.0||^3.0",
"psr/http-message": "^1.1||^2.0",
"psr/log": "^2.0||^3.0"
},
"require-dev": {
"guzzlehttp/promises": "^2.0",
"kelvinmo/simplejwt": "^1.1.0",
"phpseclib/phpseclib": "^3.0.35",
"phpspec/prophecy-phpunit": "^2.1",
"phpunit/phpunit": "^9.6",
"sebastian/comparator": ">=1.2.3",
"squizlabs/php_codesniffer": "^4.0",
"symfony/filesystem": "^6.3||^7.3",
"symfony/process": "^6.0||^7.0",
"webmozart/assert": "^1.11||^2.0"
},
"suggest": {
"phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2."
},
"type": "library",
"autoload": {
"psr-4": {
"Google\\Auth\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Google Auth Library for PHP",
"homepage": "https://github.com/google/google-auth-library-php",
"keywords": [
"Authentication",
"google",
"oauth2"
],
"support": {
"docs": "https://cloud.google.com/php/docs/reference/auth/latest",
"issues": "https://github.com/googleapis/google-auth-library-php/issues",
"source": "https://github.com/googleapis/google-auth-library-php/tree/v1.50.1"
},
"time": "2026-03-18T20:03:29+00:00"
},
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
"version": "v1.1.4", "version": "v1.1.4",
@@ -1914,6 +2259,54 @@
}, },
"time": "2026-01-23T15:30:45+00:00" "time": "2026-01-23T15:30:45+00:00"
}, },
{
"name": "league/flysystem-webdav",
"version": "3.31.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-webdav.git",
"reference": "1705b5d4aab6d755a78ea850e523dda9c58ad20f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-webdav/zipball/1705b5d4aab6d755a78ea850e523dda9c58ad20f",
"reference": "1705b5d4aab6d755a78ea850e523dda9c58ad20f",
"shasum": ""
},
"require": {
"league/flysystem": "^3.6.0",
"php": "^8.0.2",
"sabre/dav": "^4.6.0"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\WebDAV\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "WebDAV filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"WebDAV",
"file",
"files",
"filesystem"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-webdav/tree/3.31.0"
},
"time": "2026-01-23T15:30:45+00:00"
},
{ {
"name": "league/mime-type-detection", "name": "league/mime-type-detection",
"version": "1.16.0", "version": "1.16.0",
@@ -2866,6 +3259,55 @@
], ],
"time": "2025-12-27T19:41:33+00:00" "time": "2025-12-27T19:41:33+00:00"
}, },
{
"name": "psr/cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/cache.git",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for caching libraries",
"keywords": [
"cache",
"psr",
"psr-6"
],
"support": {
"source": "https://github.com/php-fig/cache/tree/3.0.0"
},
"time": "2021-02-03T23:26:27+00:00"
},
{ {
"name": "psr/clock", "name": "psr/clock",
"version": "1.0.0", "version": "1.0.0",
@@ -3555,6 +3997,451 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "time": "2025-12-14T04:43:48+00:00"
}, },
{
"name": "sabre/dav",
"version": "4.7.0",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/dav.git",
"reference": "074373bcd689a30bcf5aaa6bbb20a3395964ce7a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/dav/zipball/074373bcd689a30bcf5aaa6bbb20a3395964ce7a",
"reference": "074373bcd689a30bcf5aaa6bbb20a3395964ce7a",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-date": "*",
"ext-dom": "*",
"ext-iconv": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"ext-spl": "*",
"lib-libxml": ">=2.7.0",
"php": "^7.1.0 || ^8.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"sabre/event": "^5.0",
"sabre/http": "^5.0.5",
"sabre/uri": "^2.0",
"sabre/vobject": "^4.2.1",
"sabre/xml": "^2.0.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.19",
"monolog/monolog": "^1.27 || ^2.0",
"phpstan/phpstan": "^0.12 || ^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"ext-curl": "*",
"ext-imap": "*",
"ext-pdo": "*"
},
"bin": [
"bin/sabredav",
"bin/naturalselection"
],
"type": "library",
"autoload": {
"psr-4": {
"Sabre\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "WebDAV Framework for PHP",
"homepage": "http://sabre.io/",
"keywords": [
"CalDAV",
"CardDAV",
"WebDAV",
"framework",
"iCalendar"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/dav/issues",
"source": "https://github.com/fruux/sabre-dav"
},
"time": "2024-10-29T11:46:02+00:00"
},
{
"name": "sabre/event",
"version": "5.1.8",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/event.git",
"reference": "1dd5f55421b0092006510264131a4d632d02d2c1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/event/zipball/1dd5f55421b0092006510264131a4d632d02d2c1",
"reference": "1dd5f55421b0092006510264131a4d632d02d2c1",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1||^3.95",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/coroutine.php",
"lib/Loop/functions.php",
"lib/Promise/functions.php"
],
"psr-4": {
"Sabre\\Event\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "sabre/event is a library for lightweight event-based programming",
"homepage": "http://sabre.io/event/",
"keywords": [
"EventEmitter",
"async",
"coroutine",
"eventloop",
"events",
"hooks",
"plugin",
"promise",
"reactor",
"signal"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/event/issues",
"source": "https://github.com/fruux/sabre-event"
},
"time": "2026-04-27T04:19:36+00:00"
},
{
"name": "sabre/http",
"version": "5.1.13",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/http.git",
"reference": "7c2a14097d1a0de2347dcbdc91a02f38e338f4db"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/http/zipball/7c2a14097d1a0de2347dcbdc91a02f38e338f4db",
"reference": "7c2a14097d1a0de2347dcbdc91a02f38e338f4db",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-curl": "*",
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/event": ">=4.0 <6.0",
"sabre/uri": "^2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1||3.63.2",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"ext-curl": " to make http requests with the Client class"
},
"type": "library",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\HTTP\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "The sabre/http library provides utilities for dealing with http requests and responses. ",
"homepage": "https://github.com/fruux/sabre-http",
"keywords": [
"http"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/http/issues",
"source": "https://github.com/fruux/sabre-http"
},
"time": "2025-09-09T10:21:47+00:00"
},
{
"name": "sabre/uri",
"version": "2.3.4",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/uri.git",
"reference": "b76524c22de90d80ca73143680a8e77b1266c291"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/uri/zipball/b76524c22de90d80ca73143680a8e77b1266c291",
"reference": "b76524c22de90d80ca73143680a8e77b1266c291",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.63",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-strict-rules": "^1.6",
"phpunit/phpunit": "^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\Uri\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "Functions for making sense out of URIs.",
"homepage": "http://sabre.io/uri/",
"keywords": [
"rfc3986",
"uri",
"url"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/uri/issues",
"source": "https://github.com/fruux/sabre-uri"
},
"time": "2024-08-27T12:18:16+00:00"
},
{
"name": "sabre/vobject",
"version": "4.6.0",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/vobject.git",
"reference": "9432544fc369851fb8202c5d91159b2e669f0c88"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/9432544fc369851fb8202c5d91159b2e669f0c88",
"reference": "9432544fc369851fb8202c5d91159b2e669f0c88",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1",
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
"phpunit/php-invoker": "^2.0 || ^3.1",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"suggest": {
"hoa/bench": "If you would like to run the benchmark scripts"
},
"bin": [
"bin/vobject",
"bin/generate_vcards"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabre\\VObject\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Dominik Tobschall",
"email": "dominik@fruux.com",
"homepage": "http://tobschall.de/",
"role": "Developer"
},
{
"name": "Ivan Enderlin",
"email": "ivan.enderlin@hoa-project.net",
"homepage": "http://mnt.io/",
"role": "Developer"
}
],
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
"homepage": "http://sabre.io/vobject/",
"keywords": [
"availability",
"freebusy",
"iCalendar",
"ical",
"ics",
"jCal",
"jCard",
"recurrence",
"rfc2425",
"rfc2426",
"rfc2739",
"rfc4770",
"rfc5545",
"rfc5546",
"rfc6321",
"rfc6350",
"rfc6351",
"rfc6474",
"rfc6638",
"rfc6715",
"rfc6868",
"vCalendar",
"vCard",
"vcf",
"xCal",
"xCard"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/vobject/issues",
"source": "https://github.com/fruux/sabre-vobject"
},
"time": "2026-05-31T13:04:55+00:00"
},
{
"name": "sabre/xml",
"version": "2.2.11",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/xml.git",
"reference": "01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/xml/zipball/01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc",
"reference": "01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"lib-libxml": ">=2.6.20",
"php": "^7.1 || ^8.0",
"sabre/uri": ">=1.0,<3.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1||3.63.2",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/Deserializer/functions.php",
"lib/Serializer/functions.php"
],
"psr-4": {
"Sabre\\Xml\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Markus Staab",
"email": "markus.staab@redaxo.de",
"role": "Developer"
}
],
"description": "sabre/xml is an XML library that you may not hate.",
"homepage": "https://sabre.io/xml/",
"keywords": [
"XMLReader",
"XMLWriter",
"dom",
"xml"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/xml/issues",
"source": "https://github.com/fruux/sabre-xml"
},
"time": "2024-09-06T07:37:46+00:00"
},
{ {
"name": "spatie/laravel-package-tools", "name": "spatie/laravel-package-tools",
"version": "1.93.0", "version": "1.93.0",
+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 public function up(): void
{ {
if (Schema::hasTable('tenants')) {
return;
}
Schema::create('tenants', function (Blueprint $table) { Schema::create('tenants', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome'); $table->string('nome');
@@ -8,13 +8,16 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('diocesi')) {
Schema::create('diocesi', function (Blueprint $table) { Schema::create('diocesi', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome'); $table->string('nome');
$table->string('regione')->nullable(); $table->string('regione')->nullable();
$table->timestamps(); $table->timestamps();
}); });
}
if (!Schema::hasTable('comuni')) {
Schema::create('comuni', function (Blueprint $table) { Schema::create('comuni', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome'); $table->string('nome');
@@ -29,6 +32,7 @@ return new class extends Migration
$table->index('sigla_provincia'); $table->index('sigla_provincia');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('individui')) {
Schema::create('individui', function (Blueprint $table) { Schema::create('individui', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -30,6 +31,7 @@ return new class extends Migration
$table->index('cognome'); $table->index('cognome');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('contatti')) {
Schema::create('contatti', function (Blueprint $table) { Schema::create('contatti', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade'); $table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
@@ -19,6 +20,7 @@ return new class extends Migration
$table->index('individuo_id'); $table->index('individuo_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('gruppi')) {
Schema::create('gruppi', function (Blueprint $table) { Schema::create('gruppi', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -27,6 +28,7 @@ return new class extends Migration
$table->index('parent_id'); $table->index('parent_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('gruppo_individuo')) {
Schema::create('gruppo_individuo', function (Blueprint $table) { Schema::create('gruppo_individuo', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade'); $table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
@@ -18,6 +19,7 @@ return new class extends Migration
$table->unique(['gruppo_id', 'individuo_id']); $table->unique(['gruppo_id', 'individuo_id']);
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('eventi')) {
Schema::create('eventi', function (Blueprint $table) { Schema::create('eventi', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -23,14 +24,18 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
$table->index('tenant_id'); $table->index('tenant_id');
}); });
}
if (!Schema::hasTable('eventi_gruppi')) {
Schema::create('eventi_gruppi', function (Blueprint $table) { Schema::create('eventi_gruppi', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade'); $table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade'); $table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->timestamps(); $table->timestamps();
}); });
}
if (!Schema::hasTable('eventi_responsabili')) {
Schema::create('eventi_responsabili', function (Blueprint $table) { Schema::create('eventi_responsabili', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade'); $table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
@@ -38,6 +43,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('documenti')) {
Schema::create('documenti', function (Blueprint $table) { Schema::create('documenti', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -25,6 +26,7 @@ return new class extends Migration
$table->index('tenant_id'); $table->index('tenant_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('mailing_lists')) {
Schema::create('mailing_lists', function (Blueprint $table) { Schema::create('mailing_lists', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -17,7 +18,9 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
$table->index('tenant_id'); $table->index('tenant_id');
}); });
}
if (!Schema::hasTable('mailing_contacts')) {
Schema::create('mailing_contacts', function (Blueprint $table) { Schema::create('mailing_contacts', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('mailing_list_id')->constrained('mailing_lists')->onDelete('cascade'); $table->foreignId('mailing_list_id')->constrained('mailing_lists')->onDelete('cascade');
@@ -28,7 +31,9 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
$table->unique(['mailing_list_id', 'individuo_id']); $table->unique(['mailing_list_id', 'individuo_id']);
}); });
}
if (!Schema::hasTable('mailing_messaggi')) {
Schema::create('mailing_messaggi', function (Blueprint $table) { Schema::create('mailing_messaggi', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -46,6 +51,7 @@ return new class extends Migration
$table->index('tenant_id'); $table->index('tenant_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('notifiche')) {
Schema::create('notifiche', function (Blueprint $table) { Schema::create('notifiche', function (Blueprint $table) {
$table->id(); $table->id();
$table->unsignedBigInteger('user_id')->nullable(); $table->unsignedBigInteger('user_id')->nullable();
@@ -21,6 +22,7 @@ return new class extends Migration
$table->index('user_id'); $table->index('user_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('users')) {
Schema::create('users', function (Blueprint $table) { Schema::create('users', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
@@ -21,13 +22,17 @@ return new class extends Migration
$table->index('tenant_id'); $table->index('tenant_id');
$table->index('email'); $table->index('email');
}); });
}
if (!Schema::hasTable('password_reset_tokens')) {
Schema::create('password_reset_tokens', function (Blueprint $table) { Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary(); $table->string('email')->primary();
$table->string('token'); $table->string('token');
$table->timestamp('created_at')->nullable(); $table->timestamp('created_at')->nullable();
}); });
}
if (!Schema::hasTable('sessions')) {
Schema::create('sessions', function (Blueprint $table) { Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary(); $table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index(); $table->foreignId('user_id')->nullable()->index();
@@ -37,6 +42,7 @@ return new class extends Migration
$table->integer('last_activity')->index(); $table->integer('last_activity')->index();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,18 +8,23 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('cache')) {
Schema::create('cache', function (Blueprint $table) { Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary(); $table->string('key')->primary();
$table->mediumText('value'); $table->mediumText('value');
$table->integer('expiration'); $table->integer('expiration');
}); });
}
if (!Schema::hasTable('cache_locks')) {
Schema::create('cache_locks', function (Blueprint $table) { Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary(); $table->string('key')->primary();
$table->string('owner'); $table->string('owner');
$table->integer('expiration'); $table->integer('expiration');
}); });
}
if (!Schema::hasTable('jobs')) {
Schema::create('jobs', function (Blueprint $table) { Schema::create('jobs', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('queue')->index(); $table->string('queue')->index();
@@ -29,7 +34,9 @@ return new class extends Migration
$table->unsignedInteger('available_at'); $table->unsignedInteger('available_at');
$table->unsignedInteger('created_at'); $table->unsignedInteger('created_at');
}); });
}
if (!Schema::hasTable('job_batches')) {
Schema::create('job_batches', function (Blueprint $table) { Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary(); $table->string('id')->primary();
$table->string('name'); $table->string('name');
@@ -42,7 +49,9 @@ return new class extends Migration
$table->integer('created_at'); $table->integer('created_at');
$table->integer('finished_at')->nullable(); $table->integer('finished_at')->nullable();
}); });
}
if (!Schema::hasTable('failed_jobs')) {
Schema::create('failed_jobs', function (Blueprint $table) { Schema::create('failed_jobs', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('uuid')->unique(); $table->string('uuid')->unique();
@@ -53,6 +62,7 @@ return new class extends Migration
$table->timestamp('failed_at'); $table->timestamp('failed_at');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,10 +8,12 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('individui', 'numero_documento')) {
Schema::table('individui', function (Blueprint $table) { Schema::table('individui', function (Blueprint $table) {
$table->string('numero_documento', 50)->nullable()->after('tipo_documento'); $table->string('numero_documento', 50)->nullable()->after('tipo_documento');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,15 +8,19 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (Schema::hasColumn('individui', 'telegram_id')) {
Schema::table('individui', function (Blueprint $table) { Schema::table('individui', function (Blueprint $table) {
$table->dropColumn('telegram_id'); $table->dropColumn('telegram_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
if (!Schema::hasColumn('individui', 'telegram_id')) {
Schema::table('individui', function (Blueprint $table) { Schema::table('individui', function (Blueprint $table) {
$table->string('telegram_id', 100)->nullable()->after('note'); $table->string('telegram_id', 100)->nullable()->after('note');
}); });
} }
}
}; };
@@ -8,12 +8,14 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('eventi', 'giorno_mese')) {
Schema::table('eventi', function (Blueprint $table) { Schema::table('eventi', function (Blueprint $table) {
$table->string('giorno_mese')->nullable()->after('giorno_settimana'); $table->string('giorno_mese')->nullable()->after('giorno_settimana');
$table->string('mesi_recorrenza')->nullable()->after('giorno_mese'); $table->string('mesi_recorrenza')->nullable()->after('giorno_mese');
$table->string('mese_annuale')->nullable()->after('mesi_recorrenza'); $table->string('mese_annuale')->nullable()->after('mesi_recorrenza');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,10 +8,12 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('eventi', 'occorrenza_mese')) {
Schema::table('eventi', function (Blueprint $table) { Schema::table('eventi', function (Blueprint $table) {
$table->unsignedTinyInteger('occorrenza_mese')->nullable()->after('giorno_mese'); $table->unsignedTinyInteger('occorrenza_mese')->nullable()->after('giorno_mese');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('eventi_documenti')) {
Schema::create('eventi_documenti', function (Blueprint $table) { Schema::create('eventi_documenti', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade'); $table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
@@ -15,6 +16,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,10 +8,12 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('documenti', 'user_id')) {
Schema::table('documenti', function (Blueprint $table) { Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id'); $table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('viste_report')) {
Schema::create('viste_report', function (Blueprint $table) { Schema::create('viste_report', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade'); $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
@@ -22,6 +23,7 @@ return new class extends Migration
$table->index('user_id'); $table->index('user_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,11 +8,13 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('viste_report', 'is_default')) {
Schema::table('viste_report', function (Blueprint $table) { Schema::table('viste_report', function (Blueprint $table) {
$table->boolean('is_default')->default(false)->after('nome'); $table->boolean('is_default')->default(false)->after('nome');
$table->index('is_default'); $table->index('is_default');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,11 +8,13 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('mailing_lists', 'user_id')) {
Schema::table('mailing_lists', function (Blueprint $table) { Schema::table('mailing_lists', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id'); $table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null')->after('tenant_id');
$table->index('user_id'); $table->index('user_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('tipologie_documenti')) {
Schema::create('tipologie_documenti', function (Blueprint $table) { Schema::create('tipologie_documenti', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome', 50)->unique(); $table->string('nome', 50)->unique();
@@ -16,6 +17,7 @@ return new class extends Migration
$table->boolean('attiva')->default(true); $table->boolean('attiva')->default(true);
$table->timestamps(); $table->timestamps();
}); });
}
$defaultTipologie = ['avatar', 'galleria', 'documento', 'statuto', 'altro']; $defaultTipologie = ['avatar', 'galleria', 'documento', 'statuto', 'altro'];
foreach ($defaultTipologie as $i => $nome) { foreach ($defaultTipologie as $i => $nome) {
@@ -8,10 +8,13 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('users', 'permissions')) {
Schema::table('users', function (Blueprint $table) { Schema::table('users', function (Blueprint $table) {
$table->json('permissions')->nullable()->after('is_admin'); $table->json('permissions')->nullable()->after('is_admin');
}); });
}
if (!Schema::hasTable('activity_logs')) {
Schema::create('activity_logs', function (Blueprint $table) { Schema::create('activity_logs', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade'); $table->foreignId('user_id')->constrained()->onDelete('cascade');
@@ -26,7 +29,9 @@ return new class extends Migration
$table->index(['created_at']); $table->index(['created_at']);
$table->index(['action']); $table->index(['action']);
}); });
}
if (!Schema::hasTable('role_presets')) {
Schema::create('role_presets', function (Blueprint $table) { Schema::create('role_presets', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('name', 50)->unique(); $table->string('name', 50)->unique();
@@ -35,6 +40,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -23,6 +23,7 @@ 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. * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/ */
if (!Schema::hasTable($tableNames['permissions'])) {
Schema::create($tableNames['permissions'], static function (Blueprint $table) { Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id $table->id(); // permission id
$table->string('name'); $table->string('name');
@@ -31,10 +32,12 @@ return new class extends Migration
$table->unique(['name', 'guard_name']); $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. * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/ */
if (!Schema::hasTable($tableNames['roles'])) {
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id $table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
@@ -50,7 +53,9 @@ return new class extends Migration
$table->unique(['name', 'guard_name']); $table->unique(['name', 'guard_name']);
} }
}); });
}
if (!Schema::hasTable($tableNames['model_has_permissions'])) {
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission); $table->unsignedBigInteger($pivotPermission);
@@ -73,7 +78,9 @@ return new class extends Migration
'model_has_permissions_permission_model_type_primary'); 'model_has_permissions_permission_model_type_primary');
} }
}); });
}
if (!Schema::hasTable($tableNames['model_has_roles'])) {
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole); $table->unsignedBigInteger($pivotRole);
@@ -96,7 +103,9 @@ return new class extends Migration
'model_has_roles_role_model_type_primary'); 'model_has_roles_role_model_type_primary');
} }
}); });
}
if (!Schema::hasTable($tableNames['role_has_permissions'])) {
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission); $table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole); $table->unsignedBigInteger($pivotRole);
@@ -113,6 +122,7 @@ return new class extends Migration
$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') app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('email_settings')) {
Schema::create('email_settings', function (Blueprint $table) { Schema::create('email_settings', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('imap_host')->nullable(); $table->string('imap_host')->nullable();
@@ -29,6 +30,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('email_folders')) {
Schema::create('email_folders', function (Blueprint $table) { Schema::create('email_folders', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('name'); $table->string('name');
@@ -20,6 +21,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('email_messages')) {
Schema::create('email_messages', function (Blueprint $table) { Schema::create('email_messages', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('email_folder_id')->constrained('email_folders')->onDelete('cascade'); $table->foreignId('email_folder_id')->constrained('email_folders')->onDelete('cascade');
@@ -38,6 +39,7 @@ return new class extends Migration
$table->index('received_at'); $table->index('received_at');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('email_attachments')) {
Schema::create('email_attachments', function (Blueprint $table) { Schema::create('email_attachments', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('email_message_id')->constrained('email_messages')->onDelete('cascade'); $table->foreignId('email_message_id')->constrained('email_messages')->onDelete('cascade');
@@ -20,6 +21,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('ruoli')) {
Schema::create('ruoli', function (Blueprint $table) { Schema::create('ruoli', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nome', 50)->unique(); $table->string('nome', 50)->unique();
@@ -16,6 +17,7 @@ return new class extends Migration
$table->boolean('attiva')->default(true); $table->boolean('attiva')->default(true);
$table->timestamps(); $table->timestamps();
}); });
}
$defaultRuoli = [ $defaultRuoli = [
['nome' => 'Membro', 'descrizione' => 'Membro del gruppo'], ['nome' => 'Membro', 'descrizione' => 'Membro del gruppo'],
@@ -38,6 +40,7 @@ return new class extends Migration
]); ]);
} }
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_id')) {
Schema::table('gruppo_individuo', function (Blueprint $table) { Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->unsignedBigInteger('ruolo_id')->nullable()->after('ruolo_nel_gruppo'); $table->unsignedBigInteger('ruolo_id')->nullable()->after('ruolo_nel_gruppo');
$table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null'); $table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null');
@@ -60,6 +63,7 @@ return new class extends Migration
} }
} }
} }
}
public function down(): void public function down(): void
{ {
@@ -13,10 +13,12 @@ return new class extends Migration
$table->dropColumn('ruolo_id'); $table->dropColumn('ruolo_id');
}); });
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_ids')) {
Schema::table('gruppo_individuo', function (Blueprint $table) { Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->json('ruolo_ids')->nullable()->after('individuo_id'); $table->json('ruolo_ids')->nullable()->after('individuo_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -24,9 +26,11 @@ return new class extends Migration
$table->dropColumn('ruolo_ids'); $table->dropColumn('ruolo_ids');
}); });
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_id')) {
Schema::table('gruppo_individuo', function (Blueprint $table) { Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->unsignedBigInteger('ruolo_id')->nullable()->after('individuo_id'); $table->unsignedBigInteger('ruolo_id')->nullable()->after('individuo_id');
$table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null'); $table->foreign('ruolo_id')->references('id')->on('ruoli')->onDelete('set null');
}); });
} }
}
}; };
@@ -13,10 +13,12 @@ return new class extends Migration
$table->dropColumn('responsabile_id'); $table->dropColumn('responsabile_id');
}); });
if (!Schema::hasColumn('gruppi', 'responsabile_ids')) {
Schema::table('gruppi', function (Blueprint $table) { Schema::table('gruppi', function (Blueprint $table) {
$table->json('responsabile_ids')->nullable()->after('diocesi_id'); $table->json('responsabile_ids')->nullable()->after('diocesi_id');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -24,9 +26,11 @@ return new class extends Migration
$table->dropColumn('responsabile_ids'); $table->dropColumn('responsabile_ids');
}); });
if (!Schema::hasColumn('gruppi', 'responsabile_id')) {
Schema::table('gruppi', function (Blueprint $table) { Schema::table('gruppi', function (Blueprint $table) {
$table->unsignedBigInteger('responsabile_id')->nullable()->after('diocesi_id'); $table->unsignedBigInteger('responsabile_id')->nullable()->after('diocesi_id');
$table->foreign('responsabile_id')->references('id')->on('individui')->onDelete('set null'); $table->foreign('responsabile_id')->references('id')->on('individui')->onDelete('set null');
}); });
} }
}
}; };
@@ -8,11 +8,13 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('eventi', 'luogo_indirizzo')) {
Schema::table('eventi', function (Blueprint $table) { Schema::table('eventi', function (Blueprint $table) {
$table->string('luogo_indirizzo', 500)->nullable()->after('note'); $table->string('luogo_indirizzo', 500)->nullable()->after('note');
$table->text('luogo_url_maps')->nullable()->after('luogo_indirizzo'); $table->text('luogo_url_maps')->nullable()->after('luogo_indirizzo');
}); });
} }
}
public function down(): void 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,11 +8,22 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('app_settings')) {
return;
}
if (!Schema::hasColumn('app_settings', 'logo_path')) {
Schema::table('app_settings', function (Blueprint $table) { Schema::table('app_settings', function (Blueprint $table) {
$table->string('logo_path')->nullable()->after('logo'); $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'); $table->string('logo_small_path')->nullable()->after('logo_small');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,13 +8,25 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
Schema::table('app_settings', function (Blueprint $table) { if (!Schema::hasTable('app_settings')) {
$table->string('footer_text')->nullable()->after('nome_organizzazione'); return;
$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'); $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 public function down(): void
{ {
@@ -8,10 +8,12 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('email_settings', 'signature')) {
Schema::table('email_settings', function (Blueprint $table) { Schema::table('email_settings', function (Blueprint $table) {
$table->longText('signature')->nullable()->after('reply_to'); $table->longText('signature')->nullable()->after('reply_to');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,10 +8,12 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasColumn('email_settings', 'signature_enabled')) {
Schema::table('email_settings', function (Blueprint $table) { Schema::table('email_settings', function (Blueprint $table) {
$table->boolean('signature_enabled')->default(true)->after('signature'); $table->boolean('signature_enabled')->default(true)->after('signature');
}); });
} }
}
public function down(): void public function down(): void
{ {
@@ -8,6 +8,7 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
if (!Schema::hasTable('report_custom')) {
Schema::create('report_custom', function (Blueprint $table) { Schema::create('report_custom', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade'); $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
@@ -18,6 +19,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
}); });
} }
}
public function down(): void public function down(): void
{ {

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