Compare commits

74 Commits

Author SHA1 Message Date
inext 557bf3fcdf fix doc 2026-06-23 11:12:28 +02:00
inext f2ea5e160b fix vari - doc - viste 2026-06-23 08:48:11 +02:00
inext def8d22544 fix mailing an email 2026-06-23 08:05:51 +02:00
inext c5127da750 diagnose 2026-06-19 09:09:52 +02:00
inext baea181159 individui e gruppi 2026-06-18 19:41:27 +02:00
Mariano d2084b9d07 inizio sistemazione tag mailing list 2026-06-18 07:43:33 +02:00
Mariano 3b2ad1a0d5 altre migrations sistemate 2026-06-17 19:35:20 +02:00
Mariano 92ba47e99f modifica migrations 2026-06-17 19:18:48 +02:00
inext 9d649180a6 pèassaggio su rete esterna da sistemare tag mailinglist 2026-06-17 18:15:13 +02:00
inext 6909e21b67 pre version 2 2026-06-17 13:50:41 +02:00
inext c333bbce4e cancellazione modifiche dopo step before oauth 2026-06-16 21:43:24 +02:00
inext 6001c2e3b8 Revert OAuth2.0 2026-06-16 21:35:10 +02:00
inext 69f4d6a602 add OAUth2.0 2026-06-15 11:14:35 +02:00
inext 7973a94498 before OAuth2.0 implementation 2026-06-15 09:18:27 +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 7999c663fe ver 2.0.1 2026-06-09 11:44:40 +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
38116 changed files with 4688170 additions and 19734 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
+15 -5
View File
@@ -2,10 +2,18 @@ APP_NAME=Glastree
APP_ENV=local APP_ENV=local
APP_KEY=base64:aWz7908H9c7s+it9uMTwb6pyUrpddyMclcuN9Kzv7Ao= APP_KEY=base64:aWz7908H9c7s+it9uMTwb6pyUrpddyMclcuN9Kzv7Ao=
APP_DEBUG=true APP_DEBUG=true
APP_URL= APP_URL=http://glastree.soon.it
APP_PROTOCOL=https APP_PROTOCOL=http
FORCE_HTTPS=true FORCE_HTTPS=false
TRUSTED_PROXIES=* TRUSTED_PROXIES="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1"
#GOOGLE_CLIENT_ID=980774223097-o5h7a6kepvg69te34fof2otn7ibi9uha.apps.googleusercontent.com
#GOOGLE_CLIENT_SECRET=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
GOOGLE_CLIENT_ID=416164569291-mss5favrg1qdmbk343plaj1898g3200f.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
APP_LOCALE=it APP_LOCALE=it
APP_FALLBACK_LOCALE=it APP_FALLBACK_LOCALE=it
@@ -30,9 +38,11 @@ DB_DATABASE=glastree
DB_USERNAME=glastree DB_USERNAME=glastree
DB_PASSWORD=glastree DB_PASSWORD=glastree
SESSION_DRIVER=file SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_SECURE_COOKIE=false
SESSION_SAME_SITE=lax
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
+19 -30
View File
@@ -1,17 +1,18 @@
APP_NAME=Laravel APP_NAME="Glastree"
APP_ENV=local APP_ENV=production
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
APP_PROTOCOL=https
APP_SUBFOLDER=
FORCE_HTTPS=false
TRUSTED_PROXIES="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1"
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,32 +21,26 @@ 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=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_SECURE_COOKIE=false
SESSION_SAME_SITE=lax
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null 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 +51,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 -1
View File
@@ -1 +1 @@
{"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}} {"version":2,"defects":{"Tests\\Feature\\DocumentoLinkTest::test_admin_can_create_link_document":7,"Tests\\Feature\\DocumentoLinkTest::test_create_link_requires_url":8,"Tests\\Feature\\DocumentoLinkTest::test_create_link_validates_url_format":8,"Tests\\Feature\\DocumentoLinkTest::test_create_link_does_not_require_file":7,"Tests\\Feature\\DocumentoLinkTest::test_create_link_sets_tipo_to_link":7,"Tests\\Feature\\DocumentoLinkTest::test_create_link_with_visibility_individuo":7,"Tests\\Feature\\DocumentoLinkTest::test_create_link_with_visibility_gruppo":7,"Tests\\Feature\\DocumentoLinkTest::test_download_link_redirects_to_url":8,"Tests\\Feature\\DocumentoLinkTest::test_preview_link_returns_fallback_view":8,"Tests\\Feature\\DocumentoLinkTest::test_update_link_url":8,"Tests\\Feature\\DocumentoLinkTest::test_update_link_preserves_url_when_not_sent":8,"Tests\\Feature\\DocumentoLinkTest::test_update_link_validates_url_format":8,"Tests\\Feature\\DocumentoLinkTest::test_destroy_link_does_not_call_storage":8,"Tests\\Feature\\DocumentoLinkTest::test_mass_download_skips_links":7},"times":{"Tests\\Unit\\DocumentoLinkTest::test_is_link_returns_true_when_tipo_is_link":0.001,"Tests\\Unit\\DocumentoLinkTest::test_is_link_returns_false_when_tipo_is_upload":0.001,"Tests\\Unit\\DocumentoLinkTest::test_is_link_returns_false_when_tipo_is_allegato":0.001,"Tests\\Unit\\DocumentoLinkTest::test_is_link_returns_false_when_tipo_is_null":0.002,"Tests\\Unit\\DocumentoLinkTest::test_url_is_fillable":0.001,"Tests\\Unit\\DocumentoLinkTest::test_link_doc_has_no_file_fields":0.001,"Tests\\Feature\\DocumentoLinkTest::test_admin_can_create_link_document":0.064,"Tests\\Feature\\DocumentoLinkTest::test_create_link_requires_url":0.008,"Tests\\Feature\\DocumentoLinkTest::test_create_link_validates_url_format":0.007,"Tests\\Feature\\DocumentoLinkTest::test_create_link_does_not_require_file":0.006,"Tests\\Feature\\DocumentoLinkTest::test_create_link_sets_tipo_to_link":0.014,"Tests\\Feature\\DocumentoLinkTest::test_create_link_with_visibility_individuo":0.008,"Tests\\Feature\\DocumentoLinkTest::test_create_link_with_visibility_gruppo":0.008,"Tests\\Feature\\DocumentoLinkTest::test_download_link_redirects_to_url":0.005,"Tests\\Feature\\DocumentoLinkTest::test_preview_link_returns_fallback_view":0.009,"Tests\\Feature\\DocumentoLinkTest::test_update_link_url":0.008,"Tests\\Feature\\DocumentoLinkTest::test_update_link_preserves_url_when_not_sent":0.006,"Tests\\Feature\\DocumentoLinkTest::test_update_link_validates_url_format":0.006,"Tests\\Feature\\DocumentoLinkTest::test_destroy_link_does_not_call_storage":0.004,"Tests\\Feature\\DocumentoLinkTest::test_mass_download_skips_links":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)
+27
View File
@@ -0,0 +1,27 @@
# Fix: tabella `google_o_auth_connections` mancante
## Problema
Migration `2026_06_15_000001_create_google_oauth_connections_table.php` segnata come "Ran" (batch 39) ma tabella fisica inesistente sul database MySQL/MariaDB. Causa 500 su GET `/impostazioni`.
## Comandi (eseguire sul server `192.168.222.177`)
```bash
# 1. Creare la tabella mancante con la stessa definizione della migration
php artisan tinker --execute="
Schema::create('google_o_auth_connections', function (\Illuminate\Database\Schema\Blueprint \$table) {
\$table->id();
\$table->string('service', 20)->unique();
\$table->string('client_id', 255);
\$table->text('client_secret');
\$table->text('refresh_token');
\$table->timestamps();
});
"
# 2. Pulire la cache delle viste (stale compiled view)
php artisan view:clear
# 3. Verificare che la tabella esista
php artisan tinker --execute="echo Schema::hasTable('google_o_auth_connections') ? 'OK' : 'ERRORE';"
```
+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"]
+1149 -527
View File
File diff suppressed because it is too large Load Diff
Executable
+59
View File
@@ -0,0 +1,59 @@
# Proxy Configuration — Glastree
## Nginx Proxy Manager (Custom Nginx Configuration)
Nella scheda **Advanced** del tuo proxy host:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
```
---
## `.env` (App Laravel)
```env
APP_URL=https://glastree.soon.it
APP_PROTOCOL=https
FORCE_HTTPS=true
TRUSTED_PROXIES=*
```
Dopo qualsiasi modifica:
```bash
php artisan config:clear
```
---
## Google OAuth — Authorized Redirect URIs
```
https://glastree.soon.it/auth/google/callback
```
---
## `bootstrap/app.php`
```php
$middleware->trustProxies(at: '*');
```
---
## Schema richieste
```
Cliente (HTTPS)
Nginx Proxy Manager (termina TLS, inoltra HTTP + X-Forwarded-Proto: https)
App Laravel (vede richieste come HTTPS grazie a trustProxies(at:"*"))
```
+63
View File
@@ -0,0 +1,63 @@
<?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...');
$options = [];
if ($this->option('no-files')) {
$options['include_files'] = false;
$this->warn('Files esclusi dal backup.');
}
if ($this->option('no-env')) {
$options['include_env'] = false;
$this->warn('.env escluso dal backup.');
}
$result = $this->backupService->run($options);
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']));
if (!empty($result['steps'])) {
$this->line('Contenuto:');
foreach ($result['steps'] as $step) {
$this->line(' - ' . $step);
}
}
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;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum GoogleService: string
{
case Email = 'email';
case Drive = 'drive';
case Calendar = 'calendar';
public function scopes(): array
{
return match ($this) {
self::Email => ['https://mail.google.com/'],
self::Drive => ['https://www.googleapis.com/auth/drive'],
self::Calendar => ['https://www.googleapis.com/auth/calendar'],
};
}
public function label(): string
{
return match ($this) {
self::Email => 'Gmail',
self::Drive => 'Google Drive',
self::Calendar => 'Google Calendar',
};
}
public function icon(): string
{
return match ($this) {
self::Email => 'fas fa-envelope',
self::Drive => 'fab fa-google-drive',
self::Calendar => 'fas fa-calendar-alt',
};
}
public function description(): string
{
return match ($this) {
self::Email => 'Invia e ricevi email tramite Gmail API (alternativa a SMTP/IMAP con password)',
self::Drive => 'Accedi ai file su Google Drive per documenti e allegati',
self::Calendar => 'Sincronizza eventi con Google Calendar',
};
}
}
+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,119 @@
<?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']) {
$steps = !empty($result['steps']) ? ' — ' . implode(', ', $result['steps']) : '';
return redirect()->route('admin.backup.index')
->with('success', 'Backup completato: ' . ($result['filename'] ?? '') . ' (' . ($result['size_formatted'] ?? '') . ')' . $steps);
}
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]);
}
}
@@ -2,21 +2,36 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Enums\GoogleService;
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\Firma;
use App\Models\SenderAccount;
use App\Services\GoogleOAuthService;
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;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
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();
$googleStatus = app(GoogleOAuthService::class)->getConnectionStatus();
$firme = $settings->exists ? $settings->firme : collect();
return view('admin.email-settings.index', compact('settings', 'senderAccounts', 'googleStatus', 'firme'));
} }
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 +41,19 @@ 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',
'from_email' => 'nullable|email|max:255',
'from_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',
'auth_method' => 'nullable|in:password,oauth',
'google_oauth_connection_id' => 'nullable|integer|exists:google_oauth_connections,id',
]); ]);
if (!empty($validated['imap_password'])) { if (!empty($validated['imap_password'])) {
@@ -49,11 +72,17 @@ class EmailSettingsController extends Controller
$validated $validated
); );
// Verifica che i dati siano stati salvati
$verify = EmailSetting::find($saved->id); $verify = EmailSetting::find($saved->id);
if ($verify &&
$allGood = $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'];
$allGood = $allGood &&
(!array_key_exists('signature', $validated) || $verify->signature === $validated['signature']) &&
(!array_key_exists('signature_enabled', $validated) || (bool)$verify->signature_enabled === (bool)$validated['signature_enabled']);
if ($allGood) {
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,13 +91,13 @@ 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) {
return response()->json(['success' => false, 'message' => 'Configurazione non trovata.']); return response()->json(['success' => false, 'message' => 'Configurazione non trovata.']);
} }
// Verifica che i dati siano salvati
if (empty($settings->imap_host) || empty($settings->email_address)) { if (empty($settings->imap_host) || empty($settings->email_address)) {
return response()->json(['success' => false, 'message' => 'Prima salva le impostazioni.']); return response()->json(['success' => false, 'message' => 'Prima salva le impostazioni.']);
} }
@@ -87,6 +116,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',
]); ]);
@@ -97,60 +127,27 @@ class EmailSettingsController extends Controller
return response()->json(['success' => false, 'message' => 'Configurazione non trovata.']); return response()->json(['success' => false, 'message' => 'Configurazione non trovata.']);
} }
if (empty($settings->smtp_host)) {
$settings->smtp_host = 'smtp.gmail.com';
$settings->smtp_port = 587;
$settings->smtp_encryption = 'tls';
}
try { try {
$smtpUsername = $settings->smtp_username; $mailer = $settings->getSmtpMailer();
$smtpPassword = $settings->smtp_password;
if (empty($smtpUsername)) { if ($mailer === null) {
$smtpUsername = $settings->email_address; return response()->json(['success' => false, 'message' => 'Impossibile creare il mailer SMTP. Verifica la configurazione.']);
}
if (empty($smtpPassword)) {
$smtpPassword = $settings->getDecryptedPassword();
} else {
$smtpPassword = Crypt::decryptString($smtpPassword);
} }
$encryption = $settings->smtp_encryption ?? 'tls'; $smtpUser = $settings->smtp_username ?: $settings->email_address;
$scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp'; $fromName = $settings->getEffectiveFromName();
$replyToEmail = $settings->from_email ?: $smtpUser;
$dsn = sprintf( $email = (new Email())
'%s://%s:%s@%s:%d?encryption=%s', ->from(Address::create($smtpUser, $fromName))
$scheme,
rawurlencode($smtpUsername),
rawurlencode($smtpPassword),
$settings->smtp_host,
$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($request->email) ->to($request->email)
->subject('Test Configurazione SMTP - Glastree') ->subject('Test Configurazione SMTP - Glastree')
->text('Test email da Glastree - Configurazione SMTP funziona!'); ->text('Test email da Glastree - Configurazione SMTP funziona!');
if ($replyToEmail !== $smtpUser) {
$email->replyTo(Address::create($replyToEmail, $fromName));
}
$mailer->send($email); $mailer->send($email);
\Illuminate\Support\Facades\Log::info('Test SMTP ok', ['to' => $request->email, 'from' => $settings->email_address]); \Illuminate\Support\Facades\Log::info('Test SMTP ok', ['to' => $request->email, 'from' => $settings->email_address]);
@@ -164,6 +161,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 +176,185 @@ 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',
'auth_method' => 'nullable|in:password,oauth',
'google_oauth_connection_id' => 'nullable|integer|exists:google_oauth_connections,id',
]);
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',
'auth_method' => 'nullable|in:password,oauth',
'google_oauth_connection_id' => 'nullable|integer|exists:google_oauth_connections,id',
]);
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()]);
}
}
public function firmaStore(Request $request)
{
$this->authorizeWrite('settings');
$data = $request->validate([
'nome' => 'required|string|max:255',
'contenuto_html' => 'nullable|string',
'is_default' => 'boolean',
]);
$settings = EmailSetting::getActive();
if (!$settings) {
return redirect('/impostazioni/email')->with('error', 'Account email non configurato.');
}
$firma = $settings->firme()->create($data);
if ($data['is_default'] ?? false) {
$settings->firme()->where('id', '!=', $firma->id)->update(['is_default' => false]);
}
return redirect('/impostazioni/email#firme')->with('success', 'Firma "' . $firma->nome . '" creata.');
}
public function firmaUpdate(Request $request, int $id)
{
$this->authorizeWrite('settings');
$data = $request->validate([
'nome' => 'required|string|max:255',
'contenuto_html' => 'nullable|string',
'is_default' => 'boolean',
]);
$firma = Firma::findOrFail($id);
$firma->update($data);
if ($data['is_default'] ?? false) {
Firma::where('email_setting_id', $firma->email_setting_id)
->where('id', '!=', $firma->id)
->update(['is_default' => false]);
}
return redirect('/impostazioni/email#firme')->with('success', 'Firma "' . $firma->nome . '" aggiornata.');
}
public function firmaSetDefault(int $id)
{
$this->authorizeWrite('settings');
$firma = Firma::findOrFail($id);
Firma::where('email_setting_id', $firma->email_setting_id)->update(['is_default' => false]);
$firma->update(['is_default' => true]);
return redirect('/impostazioni/email#firme')->with('success', 'Firma "' . $firma->nome . '" impostata come predefinita.');
}
public function firmaDestroy(int $id)
{
$this->authorizeWrite('settings');
$firma = Firma::findOrFail($id);
$nome = $firma->nome;
$firma->delete();
return redirect('/impostazioni/email#firme')->with('success', 'Firma "' . $nome . '" eliminata.');
}
} }
+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->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]);
}
}
+508 -41
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')->whereNull('cartella_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)
@@ -92,50 +201,138 @@ class DocumentoController extends Controller
$tipologieValidi = TipologiaDocumento::opzioni(); $tipologieValidi = TipologiaDocumento::opzioni();
$tipologieRule = 'in:' . implode(',', $tipologieValidi); $tipologieRule = 'in:' . implode(',', $tipologieValidi);
$data = $request->validate([ $rules = [
'nome_file' => 'required|string|max:255', 'nome_file' => 'required|string|max:255',
'tipologia' => 'required|' . $tipologieRule, 'tipologia' => 'required|' . $tipologieRule,
'visibilita' => 'nullable|string', 'contesto_tipo' => 'nullable|in:individuo,gruppo,evento,mailing',
'visibilita_target_id' => 'nullable|integer', 'visibilita_target_id' => 'nullable|integer',
'visibilita_target_type' => 'nullable|string',
'note' => 'nullable|string', 'note' => 'nullable|string',
]); 'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
];
$data['visibilita'] = $request->contesto_tipo ?: 'pubblico'; if ($documento->isLink()) {
$rules['url'] = 'nullable|url|max:2048';
}
if ($request->contesto_tipo) { $data = $request->validate($rules);
$contestoTipo = $data['contesto_tipo'] ?? null;
$data['visibilita'] = $contestoTipo ?: 'pubblico';
if ($contestoTipo) {
$typeMap = [ $typeMap = [
'individuo' => Individuo::class, 'individuo' => Individuo::class,
'gruppo' => Gruppo::class, 'gruppo' => Gruppo::class,
'evento' => Evento::class, 'evento' => Evento::class,
'mailing' => \App\Models\MailingList::class, 'mailing' => \App\Models\MailingList::class,
]; ];
$data['visibilita_target_type'] = $typeMap[$request->contesto_tipo] ?? null; $data['visibilita_target_type'] = $typeMap[$contestoTipo] ?? null;
} else { } else {
$data['visibilita'] = 'pubblico'; $data['visibilita'] = 'pubblico';
$data['visibilita_target_id'] = null; $data['visibilita_target_id'] = null;
$data['visibilita_target_type'] = null; $data['visibilita_target_type'] = null;
} }
if ($documento->isLink()) {
$data['url'] = $data['url'] ?? $documento->url;
}
$documento->update($data); $documento->update($data);
return redirect('/documenti')->with('success', 'Documento aggiornato.'); if ($request->has('tags')) {
$documento->tags()->sync($request->tags);
} else {
$documento->tags()->detach();
}
$redirect = $request->input('folder_id') ? '/documenti?folder_id=' . $request->input('folder_id') : '/documenti';
return redirect($redirect)->with('success', 'Documento aggiornato.');
} }
public function store(Request $request) public function store(Request $request)
{ {
$this->authorizeWrite('documenti'); $this->authorizeWrite('documenti');
$data = $request->validate([ $tipologieValidi = TipologiaDocumento::opzioni();
$tipologieRule = 'in:' . implode(',', $tipologieValidi);
$isLink = $request->input('tipo_caricamento') === 'link';
$rules = [
'nome_file' => 'required|string|max:255', 'nome_file' => 'required|string|max:255',
'tipologia' => 'required|in:avatar,galleria,documento,statuto,altro', 'tipologia' => 'required|' . $tipologieRule,
'file' => 'required|file|max:10240',
'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',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'tipo_caricamento' => 'required|in:file,link',
'url' => 'required_if:tipo_caricamento,link|nullable|url|max:2048',
];
if (!$isLink) {
$rules['file'] = 'required|file|max:10240';
}
$data = $request->validate($rules);
if ($isLink) {
$linkVisibilitaTargetType = null;
if ($data['visibilita'] === 'individuo' && $data['visibilita_target_id']) {
$linkVisibilitaTargetType = Individuo::class;
} elseif ($data['visibilita'] === 'gruppo' && $data['visibilita_target_id']) {
$linkVisibilitaTargetType = Gruppo::class;
}
$doc = Documento::create([
'nome_file' => $data['nome_file'],
'url' => $data['url'],
'tipo' => 'link',
'tipologia' => $data['tipologia'],
'visibilita' => $data['visibilita'],
'visibilita_target_id' => $data['visibilita_target_id'] ?? null,
'visibilita_target_type' => $linkVisibilitaTargetType,
'user_id' => auth()->id(),
'cartella_id' => $data['cartella_id'] ?? null,
'repository_id' => null,
]); ]);
$file = $request->file('file'); if ($doc && $request->has('tags')) {
$path = $file->store('documenti', 'local'); $doc->tags()->sync($request->tags);
}
$redirect = $request->_redirect ?? url()->previous();
if ($request->filled('folder_id')) {
$redirect = '/documenti?folder_id=' . $request->input('folder_id');
}
return redirect($redirect)->with('success', 'Documento (link) creato.');
}
$repositoryId = $data['repository_id'] ?? null;
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']) {
@@ -144,20 +341,29 @@ class DocumentoController extends Controller
$visibilitaTargetType = Gruppo::class; $visibilitaTargetType = Gruppo::class;
} }
Documento::create([ $doc = 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,
]); ]);
if ($doc && $request->has('tags')) {
$doc->tags()->sync($request->tags);
}
$redirect = $request->_redirect ?? url()->previous(); $redirect = $request->_redirect ?? url()->previous();
if ($request->filled('folder_id')) {
$redirect = '/documenti?folder_id=' . $request->input('folder_id');
}
return redirect($redirect)->with('success', 'Documento caricato.'); return redirect($redirect)->with('success', 'Documento caricato.');
} }
@@ -166,14 +372,44 @@ 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)) { if ($documento->isLink()) {
abort(404); return redirect()->away($documento->url);
} }
$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 +421,82 @@ 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)) { if ($documento->isLink()) {
abort(404); return response()->view('documenti.preview-fallback', [
'documento' => $documento,
])->header('X-Preview-Fallback', 'true');
} }
$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->isLink()) {
Storage::disk('local')->delete($documento->file_path); if ($documento->repository_id) {
$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.');
} }
@@ -291,9 +569,10 @@ class DocumentoController extends Controller
{ {
$idsInput = $request->input('ids', ''); $idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []); $ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
$ids = array_values(array_filter($ids, fn($v) => is_numeric($v)));
if (empty($ids)) { if (empty($ids)) {
return response()->json(['linked_count' => 0, 'links' => []]); return back()->with('error', 'Nessun documento selezionato.');
} }
$documenti = Documento::whereIn('id', $ids)->get(); $documenti = Documento::whereIn('id', $ids)->get();
@@ -364,11 +643,95 @@ 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) : []);
$ids = array_values(array_filter($ids, fn($v) => is_numeric($v)));
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.');
}
$skippedLinks = 0;
$addedCount = 0;
foreach ($documenti as $documento) {
if ($documento->isLink()) {
$skippedLinks++;
continue;
}
$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) {
if (file_exists($zipPath)) {
@unlink($zipPath);
}
$message = 'Nessun file disponibile per il download.';
if ($skippedLinks > 0) {
$message .= " ({$skippedLinks} link esterni saltati)";
}
return back()->with('error', $message);
}
$message = $addedCount . ' file scaricati.';
if ($skippedLinks > 0) {
$message .= " {$skippedLinks} link esterni saltati.";
}
return response()->download($zipPath, 'documenti.zip')->deleteFileAfterSend(true)
->with('success', $message);
}
public function massDestroy(Request $request) public function massDestroy(Request $request)
{ {
$this->authorizeDelete('documenti'); $this->authorizeDelete('documenti');
$idsInput = $request->input('ids', ''); $idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []); $ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
$ids = array_values(array_filter($ids, fn($v) => is_numeric($v)));
if (empty($ids)) { if (empty($ids)) {
return back()->with('error', 'Nessun documento selezionato.'); return back()->with('error', 'Nessun documento selezionato.');
@@ -377,8 +740,21 @@ 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->isLink()) {
Storage::disk('local')->delete($documento->file_path); if ($documento->repository_id) {
$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 +775,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 +803,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.');
@@ -435,6 +829,7 @@ class DocumentoController extends Controller
$this->authorizeWrite('documenti'); $this->authorizeWrite('documenti');
$idsInput = $request->input('ids', ''); $idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []); $ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
$ids = array_values(array_filter($ids, fn($v) => is_numeric($v)));
if (empty($ids)) { if (empty($ids)) {
return back()->with('error', 'Nessun documento selezionato.'); return back()->with('error', 'Nessun documento selezionato.');
@@ -460,4 +855,76 @@ 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) : []);
$ids = array_values(array_filter($ids, fn($v) => is_numeric($v)));
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]);
}
} }
+189 -44
View File
@@ -6,10 +6,12 @@ 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;
use App\Models\Documento; use App\Models\Documento;
use App\Models\Firma;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -58,7 +60,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 +93,12 @@ 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();
$settings = EmailSetting::getActive();
$firme = $settings?->firme ?? collect();
$prefill = []; $prefill = [];
if ($request->reply_to) { if ($request->reply_to) {
@@ -108,7 +117,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', 'firme'));
} }
public function send(Request $request) public function send(Request $request)
@@ -123,13 +132,9 @@ class EmailController extends Controller
$rules['to'] = 'required'; $rules['to'] = 'required';
} }
$rules['firma_id'] = 'nullable|integer|exists:firme,id';
$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 +142,107 @@ 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;
$attachmentResult = $this->processAttachments($request);
$attachmentPaths = $attachmentResult['paths'];
$linkDocs = $attachmentResult['links'];
$firmaId = $request->firma_id;
$body = $this->appendLinksToBody($validated['body'], $linkDocs);
foreach ($recipients as $recipient) { foreach ($recipients as $recipient) {
try { try {
$this->sendViaImap($settings, $recipient, $validated['subject'], $validated['body'], $request->cc); $bodyWithSig = $this->appendSignature($body, $firmaId, $settings);
$this->sendViaImap($settings, $recipient, $validated['subject'], $bodyWithSig, $request->cc, $attachmentPaths);
$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($body, $firmaId, $settings);
\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);
$attachmentResult = $this->resolveAttachmentPaths($request);
$attachmentPaths = $attachmentResult['paths'];
$linkDocs = $attachmentResult['links'];
$settings = EmailSetting::getActive();
$body = $this->appendLinksToBody($validated['body'], $linkDocs);
$body = $this->appendSignature($body, $request->firma_id, $settings);
$ok = 0;
$errors = [];
foreach ($recipients as $recipient) {
try {
$sender->sendEmail($recipient, $validated['subject'], $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');
@@ -187,8 +255,8 @@ class EmailController extends Controller
'body_text' => $request->body, 'body_text' => $request->body,
'to_email' => $request->to, 'to_email' => $request->to,
'is_draft' => true, 'is_draft' => true,
'from_email' => $settings->email_address ?? '', 'from_email' => $settings->getEffectiveFromAddress(),
'from_name' => $settings->email_name ?? '', 'from_name' => $settings->getEffectiveFromName(),
]); ]);
return response()->json(['success' => true, 'draft_id' => $message->id]); return response()->json(['success' => true, 'draft_id' => $message->id]);
@@ -293,7 +361,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)
@@ -446,18 +514,24 @@ class EmailController extends Controller
return array_filter(array_unique($recipients)); return array_filter(array_unique($recipients));
} }
private function appendSignature(string $body, ?string $signature, bool $enabled): string private function appendSignature(string $body, int|string|null $firmaId = null, ?EmailSetting $settings = null): string
{ {
if (empty($signature) || !$enabled) { if ($firmaId) {
$firma = Firma::find($firmaId);
if ($firma && $firma->contenuto_html) {
return $body . "\n\n---\n" . $firma->contenuto_html;
}
}
if ($settings && $settings->signature && ($settings->signature_enabled ?? false)) {
return $body . "\n\n---\n" . $settings->signature;
}
return $body; return $body;
} }
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,35 +549,34 @@ 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);
$fromName = $settings->email_name ?? ''; $fromAddress = \Symfony\Component\Mime\Address::create(
if (filter_var($fromName, FILTER_VALIDATE_EMAIL)) { $settings->getEffectiveFromAddress(),
$fromName = 'Glastree'; $settings->getEffectiveFromName()
} else { );
$fromName = preg_replace('/[^\p{L}\p{N}\s]/u', '', $fromName);
$fromName = trim($fromName);
if (empty($fromName)) {
$fromName = 'Glastree';
}
}
$fromAddress = \Symfony\Component\Mime\Address::create($settings->email_address, $fromName);
$email = (new \Symfony\Component\Mime\Email()) $email = (new \Symfony\Component\Mime\Email())
->from($fromAddress) ->from($fromAddress)
->to($to) ->to($to)
->subject($subject) ->subject($subject)
->text($bodyWithSignature); ->text($body);
$replyTo = $settings->getEffectiveReplyTo();
if ($replyTo) {
$email->replyTo($replyTo);
}
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,17 +587,89 @@ class EmailController extends Controller
} }
} }
private function processAttachments(Request $request): array
{
$paths = [];
$links = [];
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->isLink()) {
$links[] = ['nome' => $doc->nome_file, 'url' => $doc->url];
} elseif ($doc->file_path && Storage::exists($doc->file_path)) {
$paths[] = Storage::path($doc->file_path);
}
}
}
return ['paths' => $paths, 'links' => $links];
}
private function resolveAttachmentPaths(Request $request): array
{
$paths = [];
$links = [];
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->isLink()) {
$links[] = ['nome' => $doc->nome_file, 'url' => $doc->url];
} elseif ($doc->file_path && \Illuminate\Support\Facades\Storage::exists($doc->file_path)) {
$paths[] = \Illuminate\Support\Facades\Storage::path($doc->file_path);
}
}
}
return ['paths' => $paths, 'links' => $links];
}
private function appendLinksToBody(string $body, array $linkDocs): string
{
if (empty($linkDocs)) {
return $body;
}
$linksText = "\n\n---\nDocumenti collegati:\n";
foreach ($linkDocs as $link) {
$linksText .= "- {$link['nome']}: {$link['url']}\n";
}
return $body . $linksText;
}
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();
$bodyWithSignature = $this->appendSignature($body, $settings->getSignature(), $settings->signature_enabled ?? false); $bodyWithSignature = $body;
$message = EmailMessage::create([ $message = EmailMessage::create([
'email_folder_id' => $sentFolder->id, 'email_folder_id' => $sentFolder->id,
'subject' => $subject, 'subject' => $subject,
'body_text' => $bodyWithSignature, 'body_text' => $bodyWithSignature,
'from_email' => $settings->email_address, 'from_email' => $settings->getEffectiveFromAddress(),
'from_name' => $settings->email_name, 'from_name' => $settings->getEffectiveFromName(),
'to_email' => implode(', ', $recipients), 'to_email' => implode(', ', $recipients),
'cc' => $cc, 'cc' => $cc,
'is_sent' => true, 'is_sent' => true,
+248 -7
View File
@@ -1,19 +1,30 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; 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 +52,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 +102,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 +133,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 +157,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 +175,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 +220,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 +244,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 +279,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 +535,136 @@ 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);
}
public function massTag(Request $request)
{
$this->authorizeWrite('eventi');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return back()->with('error', 'Nessun evento selezionato.');
}
$data = $request->validate([
'tags' => 'required|array',
'tags.*' => 'exists:tags,id',
'mode' => 'required|in:assign,remove',
]);
$tagIds = $data['tags'];
$mode = $data['mode'];
$count = 0;
Evento::whereIn('id', $ids)->chunk(100, function ($eventi) use ($tagIds, $mode, &$count) {
foreach ($eventi as $evento) {
if ($mode === 'assign') {
$evento->tags()->syncWithoutDetaching($tagIds);
} else {
$evento->tags()->detach($tagIds);
}
$count++;
}
});
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return back()->with('success', "Tag $actionLabel per $count eventi.");
}
} }
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Evento; use App\Models\Evento;
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Enums\GoogleService;
use App\Models\GoogleOAuthConnection;
use App\Services\GoogleOAuthService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
class GoogleOAuthController extends Controller
{
public function __construct(
private readonly GoogleOAuthService $googleOAuthService,
) {}
public function redirect(string $service): RedirectResponse
{
try {
$serviceEnum = GoogleService::from($service);
} catch (\ValueError $e) {
return redirect()->back()->withErrors(['error' => 'Servizio Google non valido: ' . $service]);
}
$authUrl = $this->googleOAuthService->getAuthUrl($service);
return redirect()->away($authUrl);
}
public function callback(Request $request): RedirectResponse
{
$error = $request->input('error');
if ($error) {
$message = match ($error) {
'access_denied' => 'Accesso negato. Autorizzazione non concessa.',
default => 'Errore Google: ' . $error,
};
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => $message]);
}
$code = $request->input('code');
if (!$code) {
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => 'Nessun codice di autorizzazione ricevuto']);
}
$state = $request->input('state', 'email');
try {
$service = GoogleService::from($state);
} catch (\ValueError $e) {
$service = GoogleService::Email;
}
try {
$connection = $this->googleOAuthService->handleCallback($code, $service);
$message = match ($service) {
GoogleService::Email => 'Account email Gmail connesso con successo!',
GoogleService::Drive => 'Account Google Drive connesso con successo!',
GoogleService::Calendar => 'Account Google Calendar connesso con successo!',
};
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->with('success', $message);
} catch (\Exception $e) {
Log::error('Google OAuth callback error', [
'error' => $e->getMessage(),
'service' => $service->value,
]);
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => 'Errore durante la connessione: ' . $e->getMessage()]);
}
}
public function revoke(GoogleOAuthConnection $connection): RedirectResponse
{
if ($connection->user_id !== auth()->id()) {
abort(403);
}
try {
$service = GoogleService::from($connection->service);
$message = match ($service) {
GoogleService::Email => 'Account email Gmail disconnesso.',
GoogleService::Drive => 'Account Google Drive disconnesso.',
GoogleService::Calendar => 'Account Google Calendar disconnesso.',
};
$this->googleOAuthService->revoke($connection);
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->with('success', $message);
} catch (\Exception $e) {
Log::error('Google OAuth revoke error', [
'error' => $e->getMessage(),
'connection_id' => $connection->id,
]);
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => 'Errore durante la disconnessione: ' . $e->getMessage()]);
}
}
public function status(): \Illuminate\Http\JsonResponse
{
$status = $this->googleOAuthService->getConnectionStatus();
return response()->json($status);
}
}
+297 -16
View File
@@ -7,7 +7,12 @@ 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\DB;
use Illuminate\Support\Facades\Log;
class GruppoController extends Controller class GruppoController extends Controller
{ {
@@ -16,6 +21,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 +29,29 @@ 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' => 'anno_fondazione', 'label' => 'Anno Fondazione'],
['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 +59,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', 'gruppoContatti', '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 +87,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'
)); ));
} }
@@ -87,11 +143,12 @@ class GruppoController extends Controller
public function create(Request $request) public function create(Request $request)
{ {
$this->authorizeWrite('gruppi'); $this->authorizeWrite('gruppi');
$diocesi = Diocesi::orderBy('nome')->get(); $diocesi = Diocesi::orderBy('id')->get();
$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)
@@ -99,9 +156,11 @@ class GruppoController extends Controller
$this->authorizeWrite('gruppi'); $this->authorizeWrite('gruppi');
$data = $request->validate([ $data = $request->validate([
'nome' => 'required|string|max:255', 'nome' => 'required|string|max:255',
'anno_fondazione' => 'nullable|integer|min:1800|max:' . (date('Y') + 1),
'descrizione' => 'nullable|string', 'descrizione' => 'nullable|string',
'parent_id' => 'nullable|exists:gruppi,id', 'parent_id' => 'nullable|exists:gruppi,id',
'diocesi_id' => 'nullable|exists:diocesi,id', 'diocesi_ids' => 'nullable|array',
'diocesi_ids.*' => 'exists:diocesi,id',
'responsabile_ids' => 'nullable|array', 'responsabile_ids' => 'nullable|array',
'responsabile_ids.*' => 'exists:individui,id', 'responsabile_ids.*' => 'exists:individui,id',
'indirizzo_incontro' => 'nullable|string|max:500', 'indirizzo_incontro' => 'nullable|string|max:500',
@@ -109,6 +168,13 @@ 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',
'contatti' => 'nullable|array',
'contatti.*.tipo' => 'required|string|in:email,telefono,cellulare',
'contatti.*.valore' => 'required|string|max:255',
'contatti.*.etichetta' => 'nullable|string|max:255',
'contatti.*.is_primary' => 'nullable|boolean',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
if (!empty($data['responsabile_ids'])) { if (!empty($data['responsabile_ids'])) {
@@ -117,6 +183,25 @@ class GruppoController extends Controller
$gruppo = Gruppo::create($data); $gruppo = Gruppo::create($data);
if (!empty($data['diocesi_ids'])) {
$gruppo->diocesi()->sync($data['diocesi_ids']);
}
if (!empty($data['contatti'])) {
foreach ($data['contatti'] as $contattoData) {
$gruppo->gruppoContatti()->create([
'tipo' => $contattoData['tipo'],
'valore' => $contattoData['valore'],
'etichetta' => $contattoData['etichetta'] ?? null,
'is_primary' => $contattoData['is_primary'] ?? false,
]);
}
}
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'])) {
@@ -134,9 +219,9 @@ class GruppoController extends Controller
public function show($id) public function show($id)
{ {
$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', 'gruppoContatti', '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 +230,14 @@ 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', 'gruppoContatti', 'avatar', 'tags'])->findOrFail($id);
$diocesi = Diocesi::orderBy('nome')->get(); $diocesi = Diocesi::orderBy('id')->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();
$selectedDiocesiIds = $gruppo->diocesi()->pluck('diocesi.id')->toArray();
return view('gruppi.edit', compact('gruppo', 'diocesi', 'gruppi', 'membri', 'tags', 'selectedTags', 'selectedDiocesiIds'));
} }
public function update(Request $request, $id) public function update(Request $request, $id)
@@ -158,9 +246,11 @@ class GruppoController extends Controller
$gruppo = Gruppo::findOrFail($id); $gruppo = Gruppo::findOrFail($id);
$data = $request->validate([ $data = $request->validate([
'nome' => 'required|string|max:255', 'nome' => 'required|string|max:255',
'anno_fondazione' => 'nullable|integer|min:1800|max:' . (date('Y') + 1),
'descrizione' => 'nullable|string', 'descrizione' => 'nullable|string',
'parent_id' => 'nullable|exists:gruppi,id', 'parent_id' => 'nullable|exists:gruppi,id',
'diocesi_id' => 'nullable|exists:diocesi,id', 'diocesi_ids' => 'nullable|array',
'diocesi_ids.*' => 'exists:diocesi,id',
'responsabile_ids' => 'nullable|array', 'responsabile_ids' => 'nullable|array',
'responsabile_ids.*' => 'exists:individui,id', 'responsabile_ids.*' => 'exists:individui,id',
'indirizzo_incontro' => 'nullable|string|max:500', 'indirizzo_incontro' => 'nullable|string|max:500',
@@ -168,6 +258,13 @@ 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',
'contatti' => 'nullable|array',
'contatti.*.tipo' => 'required|string|in:email,telefono,cellulare',
'contatti.*.valore' => 'required|string|max:255',
'contatti.*.etichetta' => 'nullable|string|max:255',
'contatti.*.is_primary' => 'nullable|boolean',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]); ]);
if (!empty($data['responsabile_ids'])) { if (!empty($data['responsabile_ids'])) {
@@ -178,6 +275,30 @@ class GruppoController extends Controller
$gruppo->update($data); $gruppo->update($data);
if ($request->has('diocesi_ids')) {
$gruppo->diocesi()->sync($data['diocesi_ids'] ?? []);
} else {
$gruppo->diocesi()->detach();
}
if ($request->has('contatti')) {
$gruppo->gruppoContatti()->delete();
foreach ($data['contatti'] as $contattoData) {
$gruppo->gruppoContatti()->create([
'tipo' => $contattoData['tipo'],
'valore' => $contattoData['valore'],
'etichetta' => $contattoData['etichetta'] ?? null,
'is_primary' => $contattoData['is_primary'] ?? false,
]);
}
}
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 +421,164 @@ 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',
]);
set_time_limit(0);
session_write_close();
$file = $request->file('file');
$handle = fopen($file->path(), 'r');
$header = fgetcsv($handle, 0, ',');
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;
DB::beginTransaction();
while (($row = fgetcsv($handle, 0, ',')) !== 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,
'anno_fondazione' => !empty($data['anno_fondazione']) ? (int) $data['anno_fondazione'] : null,
'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);
DB::commit();
$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', 'anno_fondazione', 'descrizione', 'parent_id', 'diocesi_id', 'indirizzo_incontro', 'cap_incontro', 'città_incontro', 'sigla_provincia_incontro'];
$example = ['Gruppo Giovani', '2020', '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();
+220 -25
View File
@@ -3,29 +3,46 @@
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\Services\GoogleOAuthService;
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();
$googleStatus = app(GoogleOAuthService::class)->getConnectionStatus();
return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'appSettings')); return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'tags', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni', 'googleStatus'));
} }
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 +51,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 +188,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 +205,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 +220,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 +233,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 +254,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 +271,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 +298,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 +311,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 +332,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 +349,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 +364,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 +375,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 +466,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');
+158 -10
View File
@@ -1,13 +1,20 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; 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\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -17,7 +24,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 +55,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)
@@ -108,8 +152,9 @@ 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('id')->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 +173,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 +204,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 +230,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 +257,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 +368,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);
@@ -367,16 +501,27 @@ public function emailList(Request $request)
'file' => 'required|file|mimes:csv,txt', 'file' => 'required|file|mimes:csv,txt',
]); ]);
set_time_limit(0);
session_write_close();
$file = $request->file('file'); $file = $request->file('file');
$handle = fopen($file->path(), 'r'); $handle = fopen($file->path(), 'r');
$header = fgetcsv($handle, 1000, ','); $header = fgetcsv($handle, 0, ',');
if ($header === false) {
fclose($handle);
return back()->with('error', 'Il file CSV non è valido.');
}
$header = array_map('trim', $header);
$imported = 0; $imported = 0;
$skipped = 0; $skipped = 0;
$errors = []; $errors = [];
$rowNumber = 1; $rowNumber = 1;
while (($row = fgetcsv($handle, 1000, ',')) !== false) { DB::beginTransaction();
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$rowNumber++; $rowNumber++;
if (count($row) < count($header)) { if (count($row) < count($header)) {
@@ -435,12 +580,15 @@ public function emailList(Request $request)
$imported++; $imported++;
} catch (\Exception $e) { } catch (\Exception $e) {
Log::warning('Errore import individuo riga ' . $rowNumber . ': ' . $e->getMessage());
$errors[] = 'Errore riga ' . $rowNumber . ': ' . $e->getMessage(); $errors[] = 'Errore riga ' . $rowNumber . ': ' . $e->getMessage();
} }
} }
fclose($handle); fclose($handle);
DB::commit();
$message = 'Importati ' . $imported . ' individui.'; $message = 'Importati ' . $imported . ' individui.';
if ($skipped > 0) { if ($skipped > 0) {
$message .= ' Saltate ' . $skipped . ' righe (vuote o incomplete).'; $message .= ' Saltate ' . $skipped . ' righe (vuote o incomplete).';
+325 -11
View File
@@ -1,12 +1,19 @@
<?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 App\Models\EmailSetting;
use App\Models\Firma;
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 +31,9 @@ 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();
$settings = EmailSetting::getActive();
$firme = $settings?->firme ?? collect();
$documentiSelezionati = collect(); $documentiSelezionati = collect();
if ($request->has('documenti_selezionati')) { if ($request->has('documenti_selezionati')) {
@@ -31,7 +41,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', 'firme'));
} }
public function invia(Request $request) public function invia(Request $request)
@@ -40,12 +50,24 @@ class MailingController extends Controller
$data = $request->validate([ $data = $request->validate([
'oggetto' => 'required|string|max:255', 'oggetto' => 'required|string|max:255',
'corpo' => 'required', 'corpo' => 'required',
'lista_id' => 'nullable|exists:mailing_liste,id', 'lista_id' => 'nullable|exists:mailing_lists,id',
'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',
'firma_id' => 'nullable|integer|exists:firme,id',
]); ]);
$sender = null;
if (!empty($data['mittente_id'])) {
$sender = SenderAccount::findOrFail($data['mittente_id']);
} elseif (!empty($data['lista_id'])) {
$lista = MailingList::with('senderAccount')->find($data['lista_id']);
if ($lista && $lista->senderAccount) {
$sender = $lista->senderAccount;
}
}
$documentiAllegati = []; $documentiAllegati = [];
if ($request->hasFile('allegato')) { if ($request->hasFile('allegato')) {
@@ -72,22 +94,127 @@ 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();
}
$attachmentResult = $this->resolveMailingAttachmentPaths($documentiAllegati);
$attachmentPaths = $attachmentResult['paths'];
$linkDocs = $attachmentResult['links'];
$firmaId = $data['firma_id'] ?? null;
if (!$firmaId && !empty($data['lista_id'])) {
$lista = MailingList::with('firma')->find($data['lista_id']);
$firmaId = $lista?->firma?->id;
}
$corpoConFirma = $this->appendMailingLinksToBody($data['corpo'], $linkDocs);
if ($firmaId) {
$firma = Firma::find($firmaId);
if ($firma && $firma->contenuto_html) {
$corpoConFirma .= "\n\n---\n" . $firma->contenuto_html;
}
}
$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,
'firma_id' => $firmaId,
]);
$ok = 0;
$fail = 0;
$logFalliti = [];
foreach ($emails as $email) {
try {
if ($sender) {
$sender->sendEmail($email, $data['oggetto'], $corpoConFirma, $attachmentPaths);
} else {
$this->sendViaSystem($email, $data['oggetto'], $corpoConFirma, $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)
{ {
$this->authorizeWrite('mailing'); $this->authorizeWrite('mailing');
$liste = MailingList::where('attiva', true)->orderBy('nome')->get(); $liste = MailingList::with('firma')->where('attiva', true)->orderBy('nome')->get();
$documenti = Documento::orderBy('nome_file')->get(); $documenti = Documento::orderBy('nome_file')->get();
$senderAccounts = SenderAccount::active()->get();
$settings = EmailSetting::getActive();
$firme = $settings?->firme ?? collect();
return view('mailing.invio', compact('liste', 'documenti')); return view('mailing.invio', compact('liste', 'documenti', 'senderAccounts', 'firme'));
} }
public function invioElabora(Request $request) public function invioElabora(Request $request)
@@ -95,14 +222,25 @@ class MailingController extends Controller
$this->authorizeWrite('mailing'); $this->authorizeWrite('mailing');
$data = $request->validate([ $data = $request->validate([
'liste' => 'required|array', 'liste' => 'required|array',
'liste.*' => 'exists:mailing_liste,id', 'liste.*' => 'exists:mailing_lists,id',
'oggetto' => 'required|string|max:255', 'oggetto' => 'required|string|max:255',
'corpo' => 'required', 'corpo' => 'required',
'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',
'firma_id' => 'nullable|integer|exists:firme,id',
]); ]);
$listeModels = MailingList::with('firma', 'senderAccount')->whereIn('id', $data['liste'])->get();
$sender = null;
if (!empty($data['mittente_id'])) {
$sender = SenderAccount::findOrFail($data['mittente_id']);
} elseif ($listeModels->count() === 1) {
$sender = $listeModels->first()->senderAccount;
}
$documentiAllegati = []; $documentiAllegati = [];
if ($request->hasFile('allegato')) { if ($request->hasFile('allegato')) {
@@ -133,10 +271,186 @@ 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.');
}
$attachmentResult = $this->resolveMailingAttachmentPaths($documentiAllegati);
$attachmentPaths = $attachmentResult['paths'];
$linkDocs = $attachmentResult['links'];
$firmaId = $data['firma_id'] ?? null;
if (!$firmaId && $listeModels->count() === 1) {
$firmaId = $listeModels->first()->firma_id;
}
$corpoConFirma = $this->appendMailingLinksToBody($data['corpo'], $linkDocs);
if ($firmaId) {
$firma = Firma::find($firmaId);
if ($firma && $firma->contenuto_html) {
$corpoConFirma .= "\n\n---\n" . $firma->contenuto_html;
}
}
$listeNomi = $listeModels->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,
'firma_id' => $firmaId,
]);
$ok = 0;
$fail = 0;
$logFalliti = [];
foreach ($emails as $email) {
try {
if ($sender) {
$sender->sendEmail($email, $data['oggetto'], $corpoConFirma, $attachmentPaths);
} else {
$this->sendViaSystem($email, $data['oggetto'], $corpoConFirma, $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);
$fromAddress = \Symfony\Component\Mime\Address::create(
$settings->getEffectiveFromAddress(),
$settings->getEffectiveFromName()
);
$email = (new \Symfony\Component\Mime\Email())
->from($fromAddress)
->to($to)
->subject($subject)
->text($body);
$replyTo = $settings->getEffectiveReplyTo();
if ($replyTo) {
$email->replyTo($replyTo);
}
foreach ($attachmentPaths as $path) {
$email->attachFromPath($path);
}
$mailer->send($email);
}
private function resolveMailingAttachmentPaths(array $documentIds): array
{
$paths = [];
$links = [];
if (empty($documentIds)) {
return ['paths' => $paths, 'links' => $links];
}
$docs = Documento::whereIn('id', $documentIds)->get();
foreach ($docs as $doc) {
if ($doc->isLink()) {
$links[] = ['nome' => $doc->nome_file, 'url' => $doc->url];
} elseif ($doc->file_path && Storage::exists($doc->file_path)) {
$paths[] = Storage::path($doc->file_path);
}
}
return ['paths' => $paths, 'links' => $links];
}
private function appendMailingLinksToBody(string $body, array $linkDocs): string
{
if (empty($linkDocs)) {
return $body;
}
$linksText = "\n\n---\nDocumenti collegati:\n";
foreach ($linkDocs as $link) {
$linksText .= "- {$link['nome']}: {$link['url']}\n";
}
return $body . $linksText;
} }
} }
+116 -9
View File
@@ -3,23 +3,68 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\MailingList; use App\Models\MailingList;
use App\Models\SenderAccount;
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();
$settings = \App\Models\EmailSetting::getActive();
$firme = $settings?->firme ?? collect();
$senderAccounts = SenderAccount::active()->get();
return view('mailing-liste.create', compact('tags', 'firme', 'senderAccounts'));
} }
public function store(Request $request) public function store(Request $request)
@@ -30,6 +75,10 @@ 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',
'firma_id' => 'nullable|integer|exists:firme,id',
'sender_account_id' => 'nullable|integer|exists:sender_accounts,id',
]); ]);
$data['user_id'] = auth()->id(); $data['user_id'] = auth()->id();
@@ -40,13 +89,19 @@ class MailingListController extends Controller
'descrizione' => $data['descrizione'] ?? null, 'descrizione' => $data['descrizione'] ?? null,
'attiva' => $data['attiva'], 'attiva' => $data['attiva'],
'user_id' => $data['user_id'], 'user_id' => $data['user_id'],
'firma_id' => $data['firma_id'] ?? null,
'sender_account_id' => $data['sender_account_id'] ?? null,
]); ]);
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 +118,20 @@ 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', 'firma', 'senderAccount'])->findOrFail($mailingList);
return view('mailing-liste.edit', compact('mailingList')); $tags = Tag::orderBy('name')->get();
$selectedTags = $mailingList->tags->pluck('id')->toArray();
$settings = \App\Models\EmailSetting::getActive();
$firme = $settings?->firme ?? collect();
$senderAccounts = SenderAccount::active()->get();
return view('mailing-liste.edit', compact('mailingList', 'tags', 'selectedTags', 'firme', 'senderAccounts'));
} }
public function update(Request $request, $mailingList) public function update(Request $request, $mailingList)
@@ -84,6 +144,10 @@ 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',
'firma_id' => 'nullable|integer|exists:firme,id',
'sender_account_id' => 'nullable|integer|exists:sender_accounts,id',
]); ]);
$data['attiva'] = $data['attiva'] ?? false; $data['attiva'] = $data['attiva'] ?? false;
@@ -92,8 +156,16 @@ class MailingListController extends Controller
'nome' => $data['nome'], 'nome' => $data['nome'],
'descrizione' => $data['descrizione'] ?? null, 'descrizione' => $data['descrizione'] ?? null,
'attiva' => $data['attiva'], 'attiva' => $data['attiva'],
'firma_id' => $data['firma_id'] ?? null,
'sender_account_id' => $data['sender_account_id'] ?? null,
]); ]);
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 +179,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,
@@ -129,6 +201,41 @@ class MailingListController extends Controller
return redirect()->route('mailing-liste.index')->with('success', 'Lista eliminata.'); return redirect()->route('mailing-liste.index')->with('success', 'Lista eliminata.');
} }
public function massTag(Request $request)
{
$this->authorizeWrite('mailing');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return back()->with('error', 'Nessuna mailing list selezionata.');
}
$data = $request->validate([
'tags' => 'required|array',
'tags.*' => 'exists:tags,id',
'mode' => 'required|in:assign,remove',
]);
$tagIds = $data['tags'];
$mode = $data['mode'];
$count = 0;
MailingList::whereIn('id', $ids)->chunk(100, function ($lists) use ($tagIds, $mode, &$count) {
foreach ($lists as $list) {
if ($mode === 'assign') {
$list->tags()->syncWithoutDetaching($tagIds);
} else {
$list->tags()->detach($tagIds);
}
$count++;
}
});
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return back()->with('success', "Tag $actionLabel per $count mailing list.");
}
public function massElimina(Request $request) public function massElimina(Request $request)
{ {
$this->authorizeDelete('mailing'); $this->authorizeDelete('mailing');
+570 -76
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 = [];
@@ -369,7 +515,7 @@ class ReportController extends Controller
$rows[] = [ $rows[] = [
'livello' => 0, 'livello' => 0,
'nome' => $gruppo->nome, 'nome' => $gruppo->nome,
'diocesi' => $gruppo->diocesi?->nome ?? '-', 'diocesi' => $gruppo->diocesi->count() > 0 ? $gruppo->diocesi->pluck('nome')->implode(', ') : '-',
'membri' => $gruppo->individui()->count(), 'membri' => $gruppo->individui()->count(),
'padre' => '-', 'padre' => '-',
]; ];
@@ -392,7 +538,7 @@ class ReportController extends Controller
$rows[] = [ $rows[] = [
'livello' => $level, 'livello' => $level,
'nome' => str_repeat(' ', $level) . $child->nome, 'nome' => str_repeat(' ', $level) . $child->nome,
'diocesi' => $child->diocesi?->nome ?? '-', 'diocesi' => $child->diocesi->count() > 0 ? $child->diocesi->pluck('nome')->implode(', ') : '-',
'membri' => $child->individui()->count(), 'membri' => $child->individui()->count(),
'padre' => $parent?->nome ?? '-', 'padre' => $parent?->nome ?? '-',
]; ];
@@ -408,7 +554,7 @@ class ReportController extends Controller
$parent = $g->parent; $parent = $g->parent;
return [ return [
'nome' => $g->full_path, 'nome' => $g->full_path,
'diocesi' => $g->diocesi?->nome ?? '-', 'diocesi' => $g->diocesi->count() > 0 ? $g->diocesi->pluck('nome')->implode(', ') : '-',
'membri' => $g->individui_count, 'membri' => $g->individui_count,
'padre' => $parent?->nome ?? '-', 'padre' => $parent?->nome ?? '-',
'responsabili' => $g->getResponsabili()->pluck('cognome')->implode(', ') ?: '-', 'responsabili' => $g->getResponsabili()->pluck('cognome')->implode(', ') ?: '-',
@@ -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->count() > 0 ? $g->diocesi->pluck('nome')->implode(', ') : '-',
'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;
} }
+81
View File
@@ -0,0 +1,81 @@
<?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, array $existingConfig = []): void
{
foreach (['username', 'password', 'client_secret', 'refresh_token', 'api_key'] as $field) {
if (!empty($config[$field]) && !str_starts_with($config[$field], 'eyJpdiI')) {
$config[$field] = Crypt::encryptString($config[$field]);
} elseif (empty($config[$field]) && !empty($existingConfig[$field])) {
$config[$field] = $existingConfig[$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),
};
}
}
+3 -3
View File
@@ -3,15 +3,15 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Diocesi extends Model class Diocesi extends Model
{ {
protected $table = 'diocesi'; protected $table = 'diocesi';
protected $fillable = ['nome', 'regione']; protected $fillable = ['nome', 'regione'];
public function gruppi(): HasMany public function gruppi(): BelongsToMany
{ {
return $this->hasMany(Gruppo::class); return $this->belongsToMany(Gruppo::class, 'diocesi_gruppo');
} }
} }
+22 -2
View File
@@ -1,7 +1,10 @@
<?php <?php
declare(strict_types=1);
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,18 +12,35 @@ 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', 'url'
]; ];
public function isLink(): bool
{
return $this->tipo === 'link';
}
public function tenant(): BelongsTo public function tenant(): BelongsTo
{ {
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);
}
}
+152 -4
View File
@@ -2,7 +2,9 @@
namespace App\Models; namespace App\Models;
use App\Services\GoogleOAuthService;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
@@ -13,14 +15,17 @@ class EmailSetting extends Model
protected $fillable = [ protected $fillable = [
'imap_host', 'imap_port', 'imap_encryption', 'imap_username', 'imap_password', 'imap_host', 'imap_port', 'imap_encryption', 'imap_username', 'imap_password',
'smtp_host', 'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password', 'smtp_host', 'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password',
'email_address', 'email_name', 'reply_to', 'sync_interval_minutes', 'email_address', 'email_name', 'from_email', 'from_name', 'reply_to',
'last_sync_at', 'is_active', 'signature', 'signature_enabled' 'sync_interval_minutes',
'last_sync_at', 'is_active', 'signature', 'signature_enabled',
'auth_method', 'google_oauth_connection_id',
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean', 'is_active' => 'boolean',
'signature_enabled' => 'boolean', 'signature_enabled' => 'boolean',
'last_sync_at' => 'datetime', 'last_sync_at' => 'datetime',
'google_oauth_connection_id' => 'integer',
]; ];
public function folders(): HasMany public function folders(): HasMany
@@ -28,8 +33,25 @@ class EmailSetting extends Model
return $this->hasMany(EmailFolder::class)->orderBy('sort_order'); return $this->hasMany(EmailFolder::class)->orderBy('sort_order');
} }
public function googleOAuthConnection(): BelongsTo
{
return $this->belongsTo(GoogleOAuthConnection::class);
}
public function getImapConfig(): array public function getImapConfig(): array
{ {
if ($this->auth_method === 'oauth') {
$token = $this->resolveOAuthToken();
return [
'host' => $this->imap_host,
'port' => $this->imap_port,
'encryption' => $this->imap_encryption,
'username' => $this->imap_username,
'authentication' => 'oauth',
'password' => $token,
];
}
return [ return [
'host' => $this->imap_host, 'host' => $this->imap_host,
'port' => $this->imap_port, 'port' => $this->imap_port,
@@ -41,6 +63,10 @@ class EmailSetting extends Model
public function getDecryptedPassword(): string public function getDecryptedPassword(): string
{ {
if ($this->auth_method === 'oauth') {
return $this->resolveOAuthToken();
}
if (empty($this->imap_password)) { if (empty($this->imap_password)) {
return ''; return '';
} }
@@ -53,6 +79,10 @@ class EmailSetting extends Model
public function getDecryptedSmtpPassword(): string public function getDecryptedSmtpPassword(): string
{ {
if ($this->auth_method === 'oauth') {
return $this->resolveOAuthToken();
}
if (empty($this->smtp_password)) { if (empty($this->smtp_password)) {
return $this->getDecryptedPassword(); return $this->getDecryptedPassword();
} }
@@ -63,6 +93,22 @@ class EmailSetting extends Model
} }
} }
protected function resolveOAuthToken(): string
{
if (!$this->relationLoaded('googleOAuthConnection')) {
$this->load('googleOAuthConnection');
}
$connection = $this->googleOAuthConnection;
if (!$connection) {
return '';
}
try {
return app(GoogleOAuthService::class)->getAccessToken($connection);
} catch (\Exception $e) {
return '';
}
}
public function getSmtpConfig(): array public function getSmtpConfig(): array
{ {
return [ return [
@@ -87,19 +133,87 @@ class EmailSetting extends Model
default => 'none', default => 'none',
}; };
return new \DirectoryTree\ImapEngine\Mailbox([ $config = [
'host' => $this->imap_host, 'host' => $this->imap_host,
'port' => $this->imap_port ?? 993, 'port' => $this->imap_port ?? 993,
'encryption' => $encryption, 'encryption' => $encryption,
'validate_cert' => false, 'validate_cert' => false,
'username' => $this->imap_username, 'username' => $this->imap_username,
'password' => $this->getDecryptedPassword(), 'password' => $this->getDecryptedPassword(),
]); ];
if ($this->auth_method === 'oauth') {
$config['authentication'] = 'oauth';
}
return new \DirectoryTree\ImapEngine\Mailbox($config);
} catch (\Exception $e) { } catch (\Exception $e) {
return null; return null;
} }
} }
public function getSmtpMailer(): ?\Symfony\Component\Mailer\Mailer
{
if ($this->auth_method === 'oauth') {
return $this->buildOAuthSmtpMailer();
}
$dsn = $this->buildSmtpDsn();
if (!$dsn) {
return null;
}
try {
$transport = \Symfony\Component\Mailer\Transport::fromDsn($dsn);
return new \Symfony\Component\Mailer\Mailer($transport);
} catch (\Exception $e) {
return null;
}
}
protected function buildOAuthSmtpMailer(): ?\Symfony\Component\Mailer\Mailer
{
$token = $this->resolveOAuthToken();
if (empty($token)) {
return null;
}
$username = $this->smtp_username ?? $this->email_address;
$host = $this->smtp_host ?? 'smtp.gmail.com';
$port = $this->smtp_port ?? 587;
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport($host, $port, true);
$transport->setUsername($username);
$transport->setPassword($token);
$transport->setAuthenticators([
new \Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator(),
]);
return new \Symfony\Component\Mailer\Mailer($transport);
}
public function buildSmtpDsn(): ?string
{
$password = $this->getDecryptedSmtpPassword();
if (empty($password)) {
return null;
}
$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 testConnection(): array public function testConnection(): array
{ {
try { try {
@@ -134,4 +248,38 @@ class EmailSetting extends Model
{ {
return $this->signature; return $this->signature;
} }
public function getEffectiveFromAddress(): string
{
return $this->from_email ?: $this->email_address;
}
public function getEffectiveFromName(): string
{
$name = $this->from_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 getEffectiveReplyTo(): ?string
{
if ($this->from_email) {
return $this->from_email;
}
return $this->reply_to ?: null;
}
public function firme(): HasMany
{
return $this->hasMany(Firma::class);
}
public function firmaPredefinita(): ?Firma
{
return $this->firme()->where('is_default', true)->first() ?? $this->firme()->first();
}
} }
+5
View File
@@ -1,7 +1,10 @@
<?php <?php
declare(strict_types=1);
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 +12,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 +34,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 = [
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Firma extends Model
{
protected $table = 'firme';
protected $fillable = [
'email_setting_id',
'nome',
'contenuto_html',
'is_default',
];
protected $casts = [
'is_default' => 'boolean',
];
public function emailSetting(): BelongsTo
{
return $this->belongsTo(EmailSetting::class);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Services\GoogleOAuthService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GoogleOAuthConnection extends Model
{
protected $table = 'google_oauth_connections';
protected $fillable = [
'user_id',
'service',
'email',
'access_token',
'refresh_token',
'expires_at',
];
protected $casts = [
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
}
public function refresh(): void
{
if (!$this->refresh_token) {
throw new \RuntimeException('Nessun refresh token disponibile per ' . $this->email);
}
app(GoogleOAuthService::class)->refreshToken($this);
}
public function getValidAccessToken(): string
{
if ($this->isExpired() && $this->refresh_token) {
$this->refresh();
}
return $this->access_token;
}
}
+25 -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\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -10,18 +11,22 @@ 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',
'nome', 'descrizione', 'indirizzo_incontro', 'cap_incontro', 'nome', 'anno_fondazione', 'descrizione', 'indirizzo_incontro', 'cap_incontro',
'città_incontro', 'sigla_provincia_incontro', 'mappa_posizione', 'metadata' 'città_incontro', 'sigla_provincia_incontro', 'mappa_posizione', 'metadata'
]; ];
protected $casts = [ protected $casts = [
'metadata' => 'array', 'metadata' => 'array',
'responsabile_ids' => 'array', 'responsabile_ids' => 'array',
'anno_fondazione' => 'integer',
]; ];
protected $appends = ['email_primaria', 'telefono_primario'];
public function tenant(): BelongsTo public function tenant(): BelongsTo
{ {
return $this->belongsTo(Tenant::class); return $this->belongsTo(Tenant::class);
@@ -37,9 +42,14 @@ class Gruppo extends Model
return $this->hasMany(Gruppo::class, 'parent_id'); return $this->hasMany(Gruppo::class, 'parent_id');
} }
public function diocesi(): BelongsTo public function diocesi(): BelongsToMany
{ {
return $this->belongsTo(Diocesi::class); return $this->belongsToMany(Diocesi::class, 'diocesi_gruppo');
}
public function gruppoContatti(): HasMany
{
return $this->hasMany(GruppoContatto::class, 'gruppo_id');
} }
public function individui(): BelongsToMany public function individui(): BelongsToMany
@@ -64,6 +74,18 @@ class Gruppo extends Model
return null; return null;
} }
public function getEmailPrimariaAttribute(): ?string
{
return $this->gruppoContatti()->where('tipo', 'email')->where('is_primary', true)->first()?->valore
?? $this->gruppoContatti()->where('tipo', 'email')->first()?->valore;
}
public function getTelefonoPrimarioAttribute(): ?string
{
return $this->gruppoContatti()->whereIn('tipo', ['telefono', 'cellulare'])->where('is_primary', true)->first()?->valore
?? $this->gruppoContatti()->whereIn('tipo', ['telefono', 'cellulare'])->first()?->valore;
}
public function getResponsabili() public function getResponsabili()
{ {
if (empty($this->responsabile_ids)) { if (empty($this->responsabile_ids)) {
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GruppoContatto extends Model
{
protected $table = 'gruppo_contatti';
protected $fillable = ['gruppo_id', 'tipo', 'valore', 'etichetta', 'is_primary'];
protected $casts = [
'is_primary' => 'boolean',
];
public function gruppo(): BelongsTo
{
return $this->belongsTo(Gruppo::class);
}
}
+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',
+14 -1
View File
@@ -5,11 +5,14 @@ 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', 'firma_id', 'sender_account_id'];
protected $casts = ['attiva' => 'boolean']; protected $casts = ['attiva' => 'boolean'];
@@ -33,6 +36,16 @@ class MailingList extends Model
return $this->hasMany(MailingMessaggio::class); return $this->hasMany(MailingMessaggio::class);
} }
public function firma(): BelongsTo
{
return $this->belongsTo(Firma::class);
}
public function senderAccount(): BelongsTo
{
return $this->belongsTo(SenderAccount::class);
}
public function getIndividui() public function getIndividui()
{ {
return Individuo::whereHas('mailingContacts', function ($q) { return Individuo::whereHas('mailingContacts', function ($q) {
+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', 'firma_id',
]; ];
protected $casts = [ protected $casts = [
'inviato_al' => 'datetime', 'inviato_al' => 'datetime',
'log_falliti' => 'array',
]; ];
public function tenant(): BelongsTo public function tenant(): BelongsTo
+198
View File
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Services\GoogleOAuthService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
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',
'auth_method',
'google_oauth_connection_id',
];
protected $casts = [
'is_active' => 'boolean',
'smtp_port' => 'integer',
'google_oauth_connection_id' => 'integer',
];
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function googleOAuthConnection(): BelongsTo
{
return $this->belongsTo(GoogleOAuthConnection::class);
}
protected function resolveOAuthToken(): string
{
if (!$this->relationLoaded('googleOAuthConnection')) {
$this->load('googleOAuthConnection');
}
$connection = $this->googleOAuthConnection;
if (!$connection) {
return '';
}
try {
return app(GoogleOAuthService::class)->getAccessToken($connection);
} catch (\Exception $e) {
return '';
}
}
public function getDecryptedPassword(): string
{
if ($this->auth_method === 'oauth') {
return $this->resolveOAuthToken();
}
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);
}
if ($this->auth_method === 'oauth') {
$mailer = $this->buildOAuthMailer();
} else {
$transport = \Symfony\Component\Mailer\Transport::fromDsn($this->buildDsn());
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
}
$mailer->send($email);
}
protected function buildOAuthMailer(): \Symfony\Component\Mailer\Mailer
{
$token = $this->resolveOAuthToken();
$username = $this->smtp_username ?? $this->email_address;
$host = $this->smtp_host ?? 'smtp.gmail.com';
$port = $this->smtp_port ?? 587;
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport($host, $port, true);
$transport->setUsername($username);
$transport->setPassword($token);
$transport->setAuthenticators([
new \Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator(),
]);
return new \Symfony\Component\Mailer\Mailer($transport);
}
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();
}
}
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
+70 -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,63 @@ 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);
$fromAddress = new Address(
$settings->getEffectiveFromAddress(),
$settings->getEffectiveFromName()
);
$email = (new Email())
->from($fromAddress)
->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
+316
View File
@@ -0,0 +1,316 @@
<?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 $options = []): 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;
$includeFiles = $options['include_files'] ?? (bool) AppSetting::getSetting('backup_include_files', true);
$includeEnv = $options['include_env'] ?? (bool) AppSetting::getSetting('backup_include_env', true);
$steps = [];
$includedComponents = [];
$steps[] = $this->backupDatabase();
$includedComponents[] = 'database';
$stepEnv = $this->backupEnv($includeEnv);
$steps[] = $stepEnv;
if ($includeEnv) {
$includedComponents[] = 'env';
}
$stepFiles = $this->backupFiles($includeFiles);
$steps[] = $stepFiles;
if ($includeFiles) {
$includedComponents[] = 'files';
}
$this->writeManifest($timestamp, $includedComponents);
$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),
'steps' => $steps,
];
} catch (\Exception $e) {
$this->cleanTemp();
Log::error('Backup fallito: ' . $e->getMessage());
return [
'success' => false,
'message' => 'Backup fallito: ' . $e->getMessage(),
'filename' => '',
'steps' => [],
];
}
}
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(bool $includeEnv = true): string
{
if (!$includeEnv) {
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(bool $includeFiles = true): string
{
if (!$includeFiles) {
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, array $includedComponents = ['database']): 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(),
'included_components' => $includedComponents,
];
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;
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\GoogleOAuthConnection;
use App\Enums\GoogleService;
use Google_Client;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class GoogleOAuthService
{
private const TOKEN_CACHE_TTL = 300;
private const MAX_RETRIES = 2;
public function buildClient(): Google_Client
{
$client = new Google_Client();
$client->setClientId(config('services.google.client_id'));
$client->setClientSecret(config('services.google.client_secret'));
$client->setRedirectUri(route('google-oauth.callback'));
$client->setAccessType('offline');
$client->setPrompt('consent');
$client->setIncludeGrantedScopes(true);
return $client;
}
public function getAuthUrl(string $service): string
{
$serviceEnum = GoogleService::from($service);
$client = $this->buildClient();
$client->setScopes($serviceEnum->scopes());
$client->setState($service);
return $client->createAuthUrl();
}
public function handleCallback(string $authorizationCode, GoogleService $service): GoogleOAuthConnection
{
$client = $this->buildClient();
$client->setScopes($service->scopes());
$client->fetchAccessTokenWithAuthCode($authorizationCode);
$tokenData = $client->getAccessToken();
if (isset($tokenData['error'])) {
throw new \RuntimeException('Google OAuth error: ' . ($tokenData['error_description'] ?? $tokenData['error']));
}
$token = $tokenData['access_token'];
$refreshToken = $tokenData['refresh_token'] ?? null;
$expiresAt = now()->addSeconds($tokenData['expires_in']);
$email = $this->getEmailFromToken($token);
$connection = GoogleOAuthConnection::updateOrCreate([
'user_id' => auth()->id(),
'service' => $service->value,
'email' => $email,
], [
'access_token' => $token,
'refresh_token' => $refreshToken,
'expires_at' => $expiresAt,
]);
return $connection;
}
public function getAccessToken(GoogleOAuthConnection $connection): string
{
$cacheKey = "google_oauth_token_{$connection->id}";
return Cache::remember($cacheKey, self::TOKEN_CACHE_TTL, function () use ($connection) {
if ($connection->isExpired()) {
$this->refreshToken($connection);
}
return $connection->access_token;
});
}
public function refreshToken(GoogleOAuthConnection $connection): void
{
if (!$connection->refresh_token) {
throw new \RuntimeException('Impossibile rinnovare: nessun refresh token');
}
$client = $this->buildClient();
$client->fetchAccessTokenWithRefreshToken($connection->refresh_token);
$tokenData = $client->getAccessToken();
if (isset($tokenData['error'])) {
$connection->delete();
Cache::forget("google_oauth_token_{$connection->id}");
throw new \RuntimeException(
'Refresh token scaduto o revocato per ' . $connection->email
);
}
$connection->access_token = $tokenData['access_token'];
$connection->expires_at = now()->addSeconds($tokenData['expires_in']);
if (isset($tokenData['refresh_token'])) {
$connection->refresh_token = $tokenData['refresh_token'];
}
$connection->save();
Cache::forget("google_oauth_token_{$connection->id}");
}
public function revoke(GoogleOAuthConnection $connection): void
{
$client = $this->buildClient();
$client->revokeToken($connection->access_token);
$connection->delete();
Cache::forget("google_oauth_token_{$connection->id}");
}
public function createXoAuth2SmtpTransport(
string $email,
string $accessToken
): \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport {
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
'smtp.gmail.com',
587,
true,
);
$transport->setUsername($email);
$transport->setPassword($accessToken);
$transport->setAuthenticators([
new \Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator(),
]);
return $transport;
}
private function getEmailFromToken(string $accessToken): string
{
$client = $this->buildClient();
$payload = $client->verifyIdToken($accessToken);
if ($payload && isset($payload['email'])) {
return $payload['email'];
}
$http = new \GuzzleHttp\Client();
$response = $http->get('https://www.googleapis.com/oauth2/v1/userinfo?alt=json', [
'headers' => ['Authorization' => "Bearer $accessToken"],
]);
$data = json_decode((string) $response->getBody(), true);
return $data['email'];
}
public function getConnectionStatus(): array
{
$connections = GoogleOAuthConnection::where('user_id', auth()->id())->get();
$status = [];
foreach (GoogleService::cases() as $service) {
$connection = $connections->firstWhere('service', $service->value);
$status[$service->value] = [
'connected' => $connection !== null,
'connection' => $connection,
'email' => $connection?->email,
'expired' => $connection?->isExpired() ?? false,
];
}
return $status;
}
}
+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;
}
}
+3 -6
View File
@@ -19,12 +19,9 @@ return Application::configure(basePath: dirname(__DIR__))
'permission' => CheckPermission::class, 'permission' => CheckPermission::class,
]); ]);
$middleware->trustProxies(at: '*'); $middleware->trustProxies(at: '*');
$middleware->web(append: [ // Web middleware defaults are already applied by the framework.
\Illuminate\Cookie\Middleware\EncryptCookies::class, // Do NOT append EncryptCookies, StartSession, etc. here — they
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, // would run twice and corrupt session cookies, causing 419 errors.
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //
Executable
+302
View File
@@ -0,0 +1,302 @@
#!/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 "==> Generazione post-deploy.sh..."
cat > post-deploy.sh << 'POSTDEPLOY'
#!/bin/bash
# post-deploy.sh — Generated by build-dist.sh
# Usage: bash post-deploy.sh {check|upgrade|install} [--yes]
set -u
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
ARTISAN="php ${APP_DIR}/artisan"
DIAGNOSE="php ${APP_DIR}/diagnose.php"
RED='\033[31m'; GREEN='\033[32m'; YELLOW='\033[33m'
CYAN='\033[36m'; BOLD='\033[1m'; NC='\033[0m'
MODE="${1:-check}"
BATCH=false; [ "${2:-}" = "--yes" ] && BATCH=true
echo -e "${CYAN}${BOLD}============================================${NC}"
echo -e "${CYAN}${BOLD} Glastree Post-Deploy — ${MODE}${NC}"
echo -e "${CYAN}${BOLD}============================================${NC}"
echo ""
IS_FRESH=false; [ ! -f "${APP_DIR}/.env" ] && IS_FRESH=true
log() { echo -e " ${BOLD}$1${NC}"; }
ok() { echo -e " ${GREEN}✅ $1${NC}"; }
warn() { echo -e " ${YELLOW}⚠ $1${NC}"; }
fail() { echo -e " ${RED}❌ $1${NC}"; }
check_artisan() {
if [ ! -f "${APP_DIR}/artisan" ]; then
fail "artisan non trovato. Esegui da root dell'applicazione."
exit 1
fi
}
run_step() {
local step="$1" desc="$2"; shift 2
log "${step} ${desc}"
if "$@" 2>&1 | sed 's/^/ /'; then
ok "${desc}"
return 0
else
local rc=$?
fail "${desc} — exit code ${rc}"
return ${rc}
fi
}
run_diagnose() {
if ${DIAGNOSE} 2>&1 | tail -5; then
ok "Diagnostica OK"
else
warn "Diagnostica ha rilevato problemi (non bloccante)"
fi
}
run_check() {
echo -e "${YELLOW}═══════════ CONTROLLO ═══════════${NC}" ; echo ""
echo -e "${BOLD}[0] Diagnostica sistema${NC}"
run_diagnose ; echo ""
echo -e "${BOLD}[1] Ambiente${NC}"
if $IS_FRESH; then warn ".env non trovato — fresh install necessario"
else ok ".env presente"
fi ; echo ""
echo -e "${BOLD}[2] Migration pendenti${NC}"
if [ -f "${APP_DIR}/artisan" ]; then
$ARTISAN migrate:status 2>&1 | grep -E '(Pending|Ran)' | head -5 || true
PENDING=$(php -r "echo trim(shell_exec('${ARTISAN} migrate:status 2>&1 | grep -c Pending || true'));" 2>/dev/null || echo "0")
if [ "${PENDING:-0}" -gt 0 ] 2>/dev/null; then
warn "${PENDING} migration da eseguire"
else
ok "Nessuna migration pendente"
fi
else
warn "artisan non disponibile — skip migration check"
fi ; echo ""
echo -e "${BOLD}[3] Storage & Permessi${NC}"
for d in storage/framework/cache/data storage/framework/sessions \
storage/framework/views storage/logs bootstrap/cache \
storage/app/public storage/app/backups; do
p="${APP_DIR}/${d}"
if [ -d "$p" ]; then
[ -w "$p" ] && ok "${d}" || warn "${d} — non scrivibile"
else
warn "${d} — mancante"
fi
done ; echo ""
echo -e "${BOLD}[4] Symlink storage${NC}"
if [ -L "${APP_DIR}/public/storage" ]; then
ok "public/storage → $(readlink "${APP_DIR}/public/storage")"
else
warn "public/storage non presente"
fi ; echo ""
echo -e "${BOLD}[5] Cache${NC}"
for d in bootstrap/cache storage/framework/cache/data \
storage/framework/sessions storage/framework/views; do
c=$(find "${APP_DIR}/${d}" -type f 2>/dev/null | wc -l)
[ "$c" -gt 0 ] && warn "${d}: ${c} file" || ok "${d}: vuoto"
done
echo "" ; echo -e "${YELLOW}═══════════ FINE CONTROLLO ═══════════${NC}"
}
run_upgrade() {
echo -e "${YELLOW}═══════════ UPGRADE ═══════════${NC}" ; check_artisan
run_step "[1/9]" "Directory storage" mkdir -p \
"${APP_DIR}/storage/framework/cache/data" \
"${APP_DIR}/storage/framework/sessions" \
"${APP_DIR}/storage/framework/views" \
"${APP_DIR}/storage/logs" "${APP_DIR}/bootstrap/cache" \
"${APP_DIR}/storage/app/public" \
"${APP_DIR}/storage/app/public/logos" \
"${APP_DIR}/storage/app/public/documenti/eventi" \
"${APP_DIR}/storage/app/backups" \
"${APP_DIR}/storage/app/documenti" \
"${APP_DIR}/storage/app/private/avatars/gruppi" || true
run_step "[2/9]" ".gitignore storage" bash -c \
"echo '*'>'${APP_DIR}/storage/app/public/.gitignore'; echo '!.gitignore'>>'${APP_DIR}/storage/app/public/.gitignore'" || true
run_step "[3/9]" "Permessi" chmod -R 775 \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
run_step "[4/9]" "Symlink storage" bash -c \
"rm -f '${APP_DIR}/public/storage'; ln -sf '../storage/app/public' '${APP_DIR}/public/storage'" || true
run_step "[5/10]" "Migrazioni DB" $ARTISAN migrate --force || true
run_step "[6/10]" "Seed diocesi" $ARTISAN db:seed --class=DiocesiSeeder --force || true
run_step "[7/10]" "Dump autoload" composer dump-autoload --no-dev --optimize || true
run_step "[8/10]" "Key generate" $ARTISAN key:generate --force || true
run_step "[9/10]" "Pulizia cache" bash -c \
"$ARTISAN config:clear && $ARTISAN route:clear && $ARTISAN view:clear" || true
run_step "[10/10]" "Verifica finale" $DIAGNOSE || true
echo "" ; echo -e "${GREEN}${BOLD}✅ Upgrade completato.${NC}"
}
run_install() {
echo -e "${YELLOW}═══════════ INSTALL (fresh) ═══════════${NC}" ; check_artisan
run_step "[1/10]" "Directory storage" mkdir -p \
"${APP_DIR}/storage/framework/cache/data" \
"${APP_DIR}/storage/framework/sessions" \
"${APP_DIR}/storage/framework/views" \
"${APP_DIR}/storage/logs" "${APP_DIR}/bootstrap/cache" \
"${APP_DIR}/storage/app/public" \
"${APP_DIR}/storage/app/public/logos" \
"${APP_DIR}/storage/app/public/documenti/eventi" \
"${APP_DIR}/storage/app/backups" \
"${APP_DIR}/storage/app/documenti" \
"${APP_DIR}/storage/app/private/avatars/gruppi" || true
run_step "[2/10]" ".gitignore storage" bash -c \
"echo '*'>'${APP_DIR}/storage/app/public/.gitignore'; echo '!.gitignore'>>'${APP_DIR}/storage/app/public/.gitignore'" || true
run_step "[3/10]" "Permessi" chmod -R 775 \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
run_step "[4/10]" "File .env" cp "${APP_DIR}/.env.example" "${APP_DIR}/.env" || true
warn "MODIFICA .env con i tuoi dati DB/URL prima di proseguire"
if ! $BATCH; then read -p " Fatto? (Invio per continuare) " _; fi
run_step "[5/10]" "Symlink storage" bash -c \
"rm -f '${APP_DIR}/public/storage'; ln -sf '../storage/app/public' '${APP_DIR}/public/storage'" || true
run_step "[6/10]" "Dump autoload" composer dump-autoload --no-dev --optimize || true
run_step "[7/10]" "Key generate" $ARTISAN key:generate --force || true
run_step "[8/11]" "Migrazioni DB" $ARTISAN migrate --force || true
run_step "[9/11]" "Seed diocesi" $ARTISAN db:seed --class=DiocesiSeeder --force || true
run_step "[10/11]" "Pulizia cache" bash -c \
"$ARTISAN config:clear && $ARTISAN route:clear && $ARTISAN view:clear" || true
run_step "[11/11]" "Verifica finale" $DIAGNOSE || true
echo "" ; echo -e "${GREEN}${BOLD}✅ Installazione completata.${NC}"
}
case "$MODE" in
check) run_check ;;
upgrade)
run_check ; echo ""
if $BATCH; then run_upgrade
else
read -p "Procedere con l'upgrade? (s/N) " c
[ "$c" = "s" ] || [ "$c" = "S" ] && run_upgrade || echo "Annullato."
fi
;;
install) run_install ;;
*)
echo "Uso: bash post-deploy.sh {check|upgrade|install} [--yes]"
echo " check — diagnostica (nessuna modifica)"
echo " upgrade — aggiorna installazione esistente"
echo " install — fresh install"
echo " --yes — batch (nessuna conferma)"
exit 1
;;
esac
POSTDEPLOY
chmod +x post-deploy.sh
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 \
.
rm -f post-deploy.sh
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 " # Upgrade di un'installazione esistente:"
echo " bash post-deploy.sh upgrade"
echo ""
echo " # Fresh install:"
echo " bash post-deploy.sh install"
echo ""
echo " # Solo controllo (nessuna modifica):"
echo " bash post-deploy.sh check"
echo ""
echo " # Batch mode (nessuna conferma):"
echo " bash post-deploy.sh upgrade --yes"
+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",
+6
View File
@@ -35,4 +35,10 @@ return [
], ],
], ],
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URI', config('app.url') . '/auth/google/callback'),
],
]; ];
+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,14 +8,15 @@ 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');
$table->string('nome_file'); $table->string('nome_file');
$table->string('file_path'); $table->string('file_path')->nullable();
$table->string('tipo')->nullable(); $table->string('tipo')->nullable();
$table->enum('tipologia', ['avatar', 'galleria', 'documento', 'statuto', 'altro'])->default('documento'); $table->string('tipologia', 50)->default('documento');
$table->enum('visibilita', ['pubblico', 'gruppo', 'individuo', 'associazione', 'federazione'])->default('gruppo'); $table->string('visibilita', 50)->default('gruppo');
$table->unsignedBigInteger('visibilita_target_id')->nullable(); $table->unsignedBigInteger('visibilita_target_id')->nullable();
$table->string('visibilita_target_type')->nullable(); $table->string('visibilita_target_type')->nullable();
$table->string('mime_type')->nullable(); $table->string('mime_type')->nullable();
@@ -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();
@@ -29,6 +30,7 @@ return new class extends Migration
]); ]);
} }
} }
}
public function down(): void public function down(): void
{ {
@@ -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
{ {

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