Compare commits

52 Commits

Author SHA1 Message Date
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
37099 changed files with 4322395 additions and 340232 deletions
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
.env
.git
.gitignore
.vite
node_modules
vendor
storage/app/backups
storage/app/documenti
storage/framework/cache
storage/framework/sessions
storage/framework/views
storage/logs
bootstrap/cache
docker-compose.yml
docker-compose.mysql.yml
+12 -29
View File
@@ -1,17 +1,14 @@
APP_NAME=Laravel
APP_NAME="Glastree"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_LOCALE=it
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_FAKER_LOCALE=it_IT
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
@@ -20,14 +17,14 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=glastree
DB_USERNAME=glastree
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@@ -35,17 +32,9 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
QUEUE_CONNECTION=file
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
CACHE_STORE=file
MAIL_MAILER=log
MAIL_SCHEME=null
@@ -56,10 +45,4 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
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}"
+3
View File
@@ -0,0 +1,3 @@
glastree*.tar.gz
glastree*.tar
.env
-3
View File
@@ -1,3 +0,0 @@
ignore-scripts=true
audit=true
prefix=/home/inext/.npm-global
-1
View File
@@ -1 +0,0 @@
{"version":2,"defects":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":7,"Tests\\Feature\\CalendarEventsTest::test_generate_settimanale_events_produces_weekly_occurrences":7},"times":{"Tests\\Unit\\ExampleTest::test_that_true_is_true":0,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.111,"Tests\\Feature\\CalendarEventsTest::test_find_first_sunday_of_june_2026":0.015,"Tests\\Feature\\CalendarEventsTest::test_find_second_saturday_of_june_2026":0.001,"Tests\\Feature\\CalendarEventsTest::test_find_fifth_sunday_returns_null_when_not_in_month":0.001,"Tests\\Feature\\CalendarEventsTest::test_generate_mensile_events_for_all_months":0.014,"Tests\\Feature\\CalendarEventsTest::test_generate_mensile_events_respects_selected_months":0.002,"Tests\\Feature\\CalendarEventsTest::test_generate_annuale_events_produces_correct_dates":0.002,"Tests\\Feature\\CalendarEventsTest::test_generate_settimanale_events_produces_weekly_occurrences":0.004}}
+1
View File
@@ -133,6 +133,7 @@ Middleware per: X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer
MEMORY.md First: Leggere e aggiornare MEMORY.md prima/dopo ogni azione. È la fonte di verità del progetto.
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.
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
-114
View File
@@ -1,114 +0,0 @@
-# AGENTS.md
## System Prompt Senior PHP 8.4 / Laravel 13 / AdminLTE / MySQL (Enterprise)
### 🎯 Ruolo
Sei un **Senior Software Engineer** specializzato in:
- PHP **8.4**
- Laravel **13**
- AdminLTE
- MySQL
- Architetture enterprise, scalabili e manutenibili
Il tuo compito è produrre codice completo, sicuro, scalabile e conforme agli standard professionali.
---
### 🧠 Gestione della memoria (`MEMORY.md`)
#Prima di **ogni azione**, devi:
#
#1. **Leggere `MEMORY.md`** per recuperare il contesto precedente.
#2. **Aggiornare `MEMORY.md`** con:
# - nuove scoperte tecniche,
# - decisioni architetturali,
# - file creati o modificati,
# - dipendenze aggiunte,
# - problemi risolti o aperti,
# - convenzioni adottate.
#
# `MEMORY.md` è la **fonte di verità** del progetto e garantisce continuità e coerenza.
### 🧠 Gestione della memoria (MEMORY.md)
Prima di ogni azione devi:
1. Leggere completamente il file `MEMORY.md` per recuperare il contesto precedente.
2. Utilizzare le informazioni contenute in `MEMORY.md` per garantire continuità, coerenza e allineamento del progetto.
---
### 📌 Regola obbligatoria di persistenza
Dopo ogni richiesta dellutente, lagente deve:
1. Rileggere completamente `MEMORY.md` prima di iniziare qualsiasi operazione.
2. Aggiornare `MEMORY.md` riscrivendolo per intero, includendo:
- nuove informazioni apprese,
- file creati o modificati,
- decisioni architetturali,
- dipendenze aggiunte,
- problemi aperti o risolti,
- stato corrente del progetto,
- qualsiasi contenuto rilevante generato nella risposta.
3. Salvare il nuovo contenuto di `MEMORY.md` nella root del repository.
4. Ogni risposta dellagente deve sempre contenere:
- loutput richiesto dallutente,
- la nuova versione completa e aggiornata di `MEMORY.md`.
Se una sezione di `MEMORY.md` non cambia, deve comunque essere mantenuta.
---
### 🧩 Stile di lavoro
- Produci **file completi**, mai snippet isolati.
- Rispetta **PSR12**, **SOLID**, **Clean Architecture**, **DDD** quando applicabile.
- Usa Laravel 13 con:
- Eloquent ORM,
- Form Request,
- Resource / ResourceCollection,
- Policy + Gate,
- Eventi + Listener,
- Modularizzazione del codice.
- Usa AdminLTE integrato con Blade e asset pipeline. Prendi a riferimento questa pagina per le viste: https://adminlte.io/themes/AdminLTE/index2.html
- Usa MySQL con:
- migrazioni complete,
- foreign key,
- indici ottimizzati,
- naming convention coerente.
---
### 🔐 Sicurezza e qualità
- Valida sempre input e output.
- Evita SQL injection, XSS, CSRF, mass assignment.
- Usa DTO o Resource per lesposizione dati.
- Scrivi test PHPUnit quando richiesto.
- Documenta con PHPDoc e README tecnici.
---
### ⚙️ Configurazione speciale
Imposta sempre:
OPENCODE_EXPERIMENTAL_LSP_TOOL=true
---
### 🧭 Modalità di risposta
Ogni volta che produci output:
1. **Controlla `MEMORY.md`**
2. Aggiorna `MEMORY.md`
3. Genera:
- file completi,
- struttura delle directory,
- istruzioni di installazione,
- comandi artisan,
- note architetturali,
- eventuali script di build.
---
### 🔗 Collegamenti utili
- [Laravel_architettura](ca://s?q=Laravel_architettura)
- [AdminLTE_integrazione](ca://s?q=AdminLTE_integrazione)
- [Pattern_enterprise_PHP](ca://s?q=Pattern_enterprise_PHP)
- [Gestione_MEMORIA_md](ca://s?q=Gestione_MEMORIA_md)
+32
View File
@@ -0,0 +1,32 @@
FROM php:8.4-apache
RUN docker-php-ext-enable opcache \
&& a2enmod rewrite
RUN apt-get update && apt-get install -y libsqlite3-dev libpng-dev libjpeg-dev libwebp-dev libzip-dev \
&& docker-php-ext-configure gd --with-jpeg --with-webp \
&& docker-php-ext-install pdo_mysql pdo_sqlite bcmath gd zip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
&& sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
COPY . /var/www/html/
RUN mkdir -p bootstrap/cache \
&& composer install --no-dev --optimize-autoloader --no-scripts \
&& mkdir -p storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
&& chown -R www-data:www-data storage bootstrap/cache
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["apache2-foreground"]
+125 -514
View File
@@ -1,551 +1,162 @@
# MEMORY.md - Glastree Project
# 🧠 MEMORY.md — Stato Progetto
## Project Context
Laravel 13 / PHP 8.4 project for parish management (Glastree). Full codebase exists and is operational.
## Obiettivo
App gestionale Laravel 13 con AdminLTE 4 per gestione Persone e Gruppi.
---
## Funzionalità Implementate
## System Status
### Page-length selector
- **4 pagine**: Individui, Eventi, Email, Gruppi
- **Valori**: 10, 20, 25, 50, 100 (default 20)
- Dropdown nel card-footer con campi hidden per preservare query params
- GruppoController: usa `LengthAwarePaginator` per paginare la collezione ordinata gerarchicamente
### ✅ FUNZIONANTE (Stable)
- Dashboard
- Individui (CRUD completo) - funziona da internet (con viste personalizzate per colonna)
- Gruppi (CRUD completo) - funziona da internet (con vista tabella/albero e colonne personalizzate)
- Documenti - funziona da internet
- Eventi (CRUD completo) - funziona da internet (con mass delete)
- Calendario eventi - funziona da internet
- Email (sistema completo)
- Impostazioni (App Settings, Logo, Email Settings)
- Mailing List - funziona da internet (con mass delete)
- Report (predefiniti e personalizzati, accesso tramite permesso 'report')
### CSV Import Gruppi
- Stesso pattern di `IndividuoController` (import GET, importStore POST, downloadTemplate GET)
- Colonne: nome, descrizione, parent_id, diocesi_id, indirizzo_incontro, cap_incontro, città_incontro, sigla_provincia_incontro
- Fault-tolerant: skip righe vuote, log errori, try/catch su ogni riga
### ⚠️ IN COMPLETAMENTO
- Sistema Email: funziona al 95%, SMTP potrebbe necessitare credenziali corrette
- Nuova implementazione: email sync logging
### ICS Import Eventi
- Usa `Sabre\VObject\Reader::read()` già in vendor/
- Mapping: SUMMARY→nome_evento, DESCRIPTION→descrizione, DTSTART→data_specifica+ora_inizio, DTEND→durata_minuti, LOCATION→luogo_indirizzo, UID→uid_esterno
- Dedup via uid_esterno
- RRULE: import come evento singolo con prima occorrenza
---
### build-dist.sh (script di distribuzione)
- Genera `glastree-YYYYMMDD_HHMM.tar.gz`
- Include: sorgenti, vendor (production)
- Esclude: .git, node_modules, tests, cache, storage content, vendor/docs/tests, Docker
- **Istruzioni post-estrazione complete**: mkdir, permessi, configurazione, cache clear
## Progress Summary
## Bug Fix Recenti
### Eventi Enhancements (Completed)
- Migration `2026_05_12_142009_add_location_to_eventi_table` added `luogo_indirizzo` and `luogo_url_maps` columns
- Evento model updated with fillable fields
- EventoController updated: index() with search + sorting, store/update with location fields
- eventi/index.blade.php: search input, sortable columns (nome_evento, created_at), delete button
- eventi/create.blade.php: "Dove" card with address + Google Maps URL fields
- eventi/edit.blade.php: "Dove" card with address + Google Maps URL fields
- eventi/show.blade.php: "Luogo" section with address, link to Google Maps, embedded map preview
### 2026-06-07 — Colonne mancanti in eventi e gruppo_individuo
- `descrizione_evento` e `is_incontro_gruppo` mancanti in install.sql → creata migration + ALTER TABLE
- `ruolo_nel_gruppo` mancante in install.sql per pivot `gruppo_individuo`
- `VistaReport.php`: aggiunto `is_default` a `$fillable`
### Responsabile Multi-select (Completed)
- Migration `2026_05_12_000003_change_responsabile_to_multi.php` changed `responsabile_id` to `responsabile_ids` JSON
- Gruppo model updated with `getResponsabiliIds()` and `getResponsabili()` methods
- GruppoController updated: index(), store(), update(), show() for multi-select
- Views updated: show, edit, create blades with multi-select support
### 2026-06-08 — build-dist.sh: "provide valid cache path" su server remoto
**Problema**: Le esclusioni in `build-dist.sh` usavano `--exclude='storage/framework/cache'` (e simili per sessions, views, logs), che escludevano **l'intera directory** dall'archivio. Dopo l'estrazione, le directory necessarie a Laravel non esistevano.
### 13 Maggio 2026 - Fixes
- **Ordinamento colonne**: Aggiunto ordinamento per Descrizione, Tipo, Gruppi, Responsabili
- **Form edit**: Aggiunti campi `tipo_evento` e `note` mancanti
- **Show view**: Corretta struttura HTML della sezione documenti
**Fix**:
1. Cambiati exclude patterns per **contenuti soltanto** (non la directory):
- `--exclude='storage/framework/cache/*'` (non `/cache`)
- `--exclude='storage/framework/sessions/*'`
- `--exclude='storage/framework/views/*'`
- `--exclude='storage/logs/*'`
- `--exclude='storage/debugbar/*'`
2. Aggiunte esclusioni per upload utente:
- `--exclude='storage/app/public'`
- `--exclude='storage/app/private'`
3. Creato `.gitignore` in `storage/framework/views/` (mancante)
4. Istruzioni post-estrazione riscritte con:
- `mkdir -p` per tutte le directory necessarie (cache, sessions, views, logs, public, backups, documenti, bootstrap/cache)
- `chmod -R 775` e `chown -R www-data:www-data` per permessi
- `php artisan config:clear`, `route:clear`, `view:clear`
### 14 Maggio 2026 - Fixes
- **Form annidati**: La sezione Documenti spostata FUORI dal form principale
- **Tipologia enum**: Aggiunti valori 'programma' e 'locandina' alla colonna tipologia in documenti
- **Visibilita enum**: Aggiunto valore 'evento' alla colonna visibilita in documenti
## Problema 419 (Page Expired) su Produzione
Il codice NON ha errori di CSRF — tutte le form hanno `@csrf`. Il problema è configurazione sessione del server:
1. `storage/framework/sessions/` non scrivibile dal web server
2. `APP_URL` in `.env` non corrisponde all'URL reale
3. `SESSION_SECURE_COOKIE=true` ma sito in HTTP
### 15 Maggio 2026 - Eventi Show
- Rimossa sezione "Dove" duplicata dalla sidebar
- Aggiunta nuova sezione "Luogo" sotto "Data e Orario" nella colonna centrale
### 15 Maggio 2026 - Email Settings
- Aggiunto pulsante "Test Invio Email (SMTP)" nella pagina impostazioni email
- Aggiunto metodo `testSmtp()` nel controller per verificare la configurazione SMTP
- Route: `POST /impostazioni/email/test-smtp`
- Campo password opzionale (non più required)
- Rimossi attributi required dai campi del form impostazioni email
### 15 Maggio 2026 - Eventi Calendar
- Nuova vista calendario con FullCalendar 6.1.10
- Routes: `GET /eventi/calendar` e `GET /eventi/calendar/events`
- Metodo `calendar()` e `calendarEvents()` in EventoController
- Sidebar: menu Eventi con sottovoci Calendario, Elenco, Nuovo Evento
- Click su evento apre la pagina show dell'evento
### 15 Maggio 2026 - Email UI
- **Compose view**: Rimossa sidebar sinistra, solo form a schermo intero
- **Sidebar menu Email**:
- Ordine: Nuova Email, In arrivo, Preferiti, Inviate, Bozze, Archivio, Cestino
- Cliccando "Email" apre direttamente inbox
- **Bottone sincronizzazione**: "Ricevi/Invia" nella pagina inbox
- **Sincronizzazione**: Limitata a 100 messaggi per cartella
### 15 Maggio 2026 - Bug Fixes
- **RFC 2822**: Usare `Address::create()` di Symfony per gestire correttamente il display name
- **Route email.sync**: Sostituito `route('email.sync.quick')` con `url('email/sync/quick')` nella vista email/index.blade.php
### 16 Maggio 2026 - Email UI Fixes
- **Footer**: Aggiunto `min-height: calc(100vh - 120px)` al content-wrapper per garantire visibilità footer
- **Pagination**: Creato custom view `vendor.pagination.simple-bootstrap-4` con caratteri `‹` e `›` invece di SVG (problema scalabilità risolto)
### 16 Maggio 2026 - Logo Upload System
- **Model**: `App\Models\AppSetting` con metodi `getLogoUrl()`, `getLogoSmallUrl()`, `getAppName()`
- **Migration**: `2026_05_16_000001_add_logo_path_to_app_settings` - aggiunge `logo_path` e `logo_small_path`
- **Controller**: `ImpostazioniController` - metodi `uploadLogo()` e `removeLogo()`
- **Routes**: `POST /impostazioni/logo`, `POST /impostazioni/logo/remove`
- **Views**: Sezione Logo in `/impostazioni` per caricare logo grande e piccolo
- **Login page**: Logo dinamico da `AppSetting::getLogoUrl()`
- **Sidebar**: Logo piccolo da `AppSetting::getLogoSmallUrl()` con fallback a icona
### 16 Maggio 2026 - Email Signature Feature
- **Migration**: `2026_05_16_000003_add_signature_to_email_settings` - aggiunge colonna `signature` (longText)
- **Migration**: `2026_05_16_000004_add_signature_enabled_to_email_settings` - aggiunge colonna `signature_enabled` (boolean, default true)
- **Model**: `EmailSetting` aggiornato con `signature` e `signature_enabled` in fillable, cast boolean, metodo `getSignature()`
- **View**: `admin/email-settings/index.blade.php` aggiornato con:
- Titolo cambiato da "Email IMAP" a "Email Settings"
- Nuova sezione "Firma" nel menu laterale
- Toggle switch "Attiva firma automatica" per abilitare/disabilitare la firma
- Editor WYSIWYG Quill.js per la composizione della firma
- Caricamento lazy di Quill.js da CDN
- **Controller**: `EmailController` aggiornato con metodo `appendSignature(body, signature, enabled)` che aggiunge la firma al body solo se abilitata
- La firma viene aggiunta sia alle email inviate via SMTP che a quelle salvate in "Invio"
- **Sidebar**: Voce di menu cambiata da "Email IMAP" a "Impostazione Email"
---
## Sistema Email - Dettagli Tecnici
### Routes Eventi
```
GET /eventi → eventi.index (elenco)
GET /eventi/calendar → eventi.calendar (calendario)
GET /eventi/calendar/events → eventi.calendar.events (eventi per FullCalendar)
GET /eventi/create → eventi.create
POST /eventi → eventi.store
GET /eventi/{id} → eventi.show
GET /eventi/{id}/edit → eventi.edit
PUT /eventi/{id} → eventi.update
DELETE /eventi/{id} → eventi.destroy
```
### Routes Email
```
POST /email/send → email.send
POST /email/sync/quick → email.sync.quick
POST /email/bulk → email.bulk
GET /email/{folder?} → email.index
POST /email/{id}/star → email.star
DELETE /email/{id} → email.destroy
```
### Folder Types
- inbox, sent, drafts, archive, starred, trash
### SMTP Configuration
- Credenziali salvate in `email_settings` con criptatura
- Password decriptata con `getDecryptedPassword()` o `getDecryptedSmtpPassword()`
- Display name validato con `filter_var($fromName, FILTER_VALIDATE_EMAIL)` - se è un'email usa "Glastree"
### Email Signature
- Campo `signature` in `email_settings` (longText, nullable)
- WYSIWYG editor Quill.js via CDN
- Metodo `appendSignature()` in EmailController aggiunge "---\n" + signature al body
- Applicato sia in `sendViaImap()` che in `storeSentMessage()`
---
## Key Decisions
### Database
- Location uses separate columns: `luogo_indirizzo` (varchar 500) and `luogo_url_maps` (text)
- Map preview via Google Maps embed iframe using the address
- Sortable columns: nome_evento, created_at, descrizione_evento, tipo_recorrenza, gruppi, responsabili
- Multi-select uses Ctrl+click pattern with array input names
### Email
- SMTP defaults to Gmail if not configured
- Email name sanitized to comply with RFC 2822
- Sincronizzazione IMAP limitata a 100 messaggi per cartella
- Signature appended with separator "---\n" before the signature text
### UI
- AdminLTE per tutte le viste
- Sidebar con menu navigazione
- Compose email senza sidebar sinistra
---
## Relevant Files
### Core Controllers
- `app/Http/Controllers/EventoController.php`: eventi with extended sorting
- `app/Http/Controllers/EmailController.php`: email system completo, signature auto-append
- `app/Http/Controllers/Admin/EmailSettingsController.php`: email settings + test SMTP
### Models
- `app/Models/EmailSetting.php`: getDecryptedPassword(), getDecryptedSmtpPassword(), getSignature()
- `app/Models/Evento.php`: fillable with location fields
- `app/Models/Gruppo.php`: getResponsabiliIds(), getResponsabili()
### Views
- `resources/views/admin/email-settings/index.blade.php`: Email settings with signature section (Quill.js WYSIWYG)
- `resources/views/eventi/index.blade.php`: sortable columns
- `resources/views/eventi/edit.blade.php`: fixed nested form, added tipo_evento and note
- `resources/views/eventi/show.blade.php`: removed duplicate "Dove", added "Luogo" section
- `resources/views/email/index.blade.php`: mailbox view con AdminLTE
- `resources/views/email/compose.blade.php`: compose senza sidebar sinistra
- `resources/views/layouts/adminlte.blade.php`: sidebar menu Email con ordine corretto
### Migrations
- `2026_05_16_000003_add_signature_to_email_settings.php`: Adds signature column to email_settings table
---
## Commands Utili
Soluzione:
```bash
# Clear all caches
php artisan optimize:clear
# Recompile views
php artisan view:cache
# Check routes
php artisan route:list --name=email
chmod -R 775 storage/framework/sessions
php artisan config:clear
```
---
### 2026-06-08 — build-dist.sh: impossibilità salvare logo su server remoto
**Problema**:
- `public/storage` era un symlink **assoluto** (`/var/www/html/glastree/storage/app/public`) incluso nell'archivio → all'estrazione su altro server puntava a path inesistente.
- `php artisan storage:link` falliva perché il symlink rotto esisteva già.
- Sottodirectory `logos/`, `documenti/eventi/`, `avatars/gruppi/` non create dal `mkdir -p`.
## Known Issues / TODO
1. SMTP authentication con Infomaniak - verificare credenziali corrette
2. Invio email richiede password SMTP corretta nel form impostazioni
**Fix**:
1. Aggiunto `--exclude='public/storage'` al tar — il symlink va ricreato sul target.
2. Aggiunto `rm -f public/storage` prima di `php artisan storage:link` per evitare conflitto.
3. Usato `ln -sf ../storage/app/public public/storage` (invece di `php artisan storage:link --relative` che richiede `symfony/filesystem` non sempre presente).
4. Aggiunte al `mkdir -p` le sottodirectory mancanti:
- `storage/app/public/logos` (logo upload)
- `storage/app/public/documenti/eventi` (event documenti)
- `storage/app/private/avatars/gruppi` (avatar gruppi)
5. Aggiunta creazione `.gitignore` in `storage/app/public/` (placeholder Laravel).
---
### Fix form annidato salvataggio email
**2026-06-08 — Problema**: Il form di DELETE era **annidato dentro** il form di SAVE in entrambe le pagine:
- `resources/views/impostazioni/index.blade.php`
- `resources/views/admin/email-settings/index.blade.php`
- HTML invalido: il browser chiudeva implicitamente il form SAVE all'apertura del form DELETE, causando l'invio alla route sbagliata.
### 16 Maggio 2026 - URL Hardcoded Fix (Internet Access)
- **Problema**: Da internet (accesso remoto) molti form e link puntavano a `http://localhost` invece del server remoto
- **Causa**: Uso di `url('/path')` in tutte le view che generava URL basati su `APP_URL=http://localhost`
- **Soluzione**: Sostituzione di tutte le occorrenze di `url('/path')` con path relativi `/path`
- **Files modificati**:
- `individui/edit.blade.php`: form action, link gruppi/documenti, fetch API calls
- `individui/show.blade.php`: link gruppi, documenti, form, fetch API calls
- `gruppi/edit.blade.php`: form action, link individui/documenti, fetch API calls
- `gruppi/show.blade.php`: link individui, eventi
- `eventi/edit.blade.php`: form action, link annulla, documenti
- `eventi/show.blade.php`: breadcrumbs, link gruppi/responsabili, documenti
- `eventi/create.blade.php`: form action, link annulla
- `eventi/index.blade.php`: link create, reset, eventi, gruppi
- `documenti/index.blade.php`: link targets, edit, delete, mass actions, fetch API
- `documenti/edit.blade.php`: form action, download, back link
- `mailing-liste/edit.blade.php`: fetch API calls
- `eventi/calendar.blade.php`: eventSource URL
- **Layout fix**: Aggiunto meta tag `csrf-token` in `adminlte.blade.php` head
**Fix**: Spostato il form DELETE fuori dal form SAVE, separato in un `div` autonomo.
---
### Fix campi mancanti in validazione EmailSettingsController@save
**2026-06-08**: `signature`, `signature_enabled`, `smtp_username`, `smtp_password` non erano nelle regole di validazione di `save()`. Laravel scarta i campi non validati, quindi non venivano mai scritti nel DB.
### 16 Maggio 2026 - Individui Edit Form Fix
- **Problema**: Form non salvava da accesso internet, il pulsante submit non funzionava
- **Causa**: Il button type="submit" non triggherava il form submission
- **Soluzione**:
- Cambiato button da `type="submit"` a `type="button"` con `onclick="document.getElementById('main-form').submit()"`
- Path relativi per tutti i form action
- CSRF token in meta tag del layout
**Fix**: Aggiunte le regole mancanti a `$request->validate()`.
---
### DiagnoseEmail Command
- `php artisan diagnose:email` — controlla APP_KEY, tabella email_settings, record, decrittazione password, sender_accounts, log errori, estensioni PHP.
### 17 Maggio 2026 - Bulk Action Error Fix
- **Problema**: Azioni massive sulle email (sposta nel cestino) mostravano popup "errore" generico
- **Causa**: `authorizeWrite()` lancia eccezione 403 HTML invece di JSON per richieste AJAX
- **Soluzione**: Aggiunto try-catch in `bulkAction()`, `markRead()` e `toggleStar()` per ritornare JSON con messaggio appropriato
### 2026-06-08 — Firma email ancora non salvata (secondo tentativo)
**Problema**: La firma (campo `signature`) non veniva salvata nonostante le regole di validazione fossero state aggiunte.
### 17 Maggio 2026 - Nginx Proxy Manager Config
- **Problema**: Email non funziona da internet (dietro NPM)
- **bootstrap/app.php**: Aggiunto `$middleware->trustProxies(at: '*')`
- **Configurazione .env**:
```
APP_URL=
APP_PROTOCOL=https
FORCE_HTTPS=true
TRUSTED_PROXIES=*
```
- **Middleware rimosso**: ForceHttps non necessario - NPM gestisce terminazione HTTPS
- **Advanced Tab NPM**: Abilitare X-Forwarded-Proto e headers proxy
**Root cause analysis**:
1. HTML checkbox `signature_enabled` senza hidden fallback: quando non spuntato, il campo non veniva inviato → non presente in `$validated``updateOrCreate` non lo aggiornava mai a `false`.
2. La vecchia pagina `/impostazioni/` aveva l'handler `shown.bs.tab` per il tab Firma (`#email-firma`) in un solo punto su due, causando possibile mancata inizializzazione dell'editor Quill su alcuni flussi di navigazione.
3. La verifica nel controller controllava solo `imap_host` e `email_address` — anche se signature/signature_enabled fallivano, l'utente vedeva "salvato con successo".
### 17 Maggio 2026 - Form Annidati Fix
- **Problema**: Contatti, gruppi e documenti in individui/edit non venivano salvati
- **Causa**: Sezioni fuori dal main-form (chiusura prematura)
- **Soluzione**: Spostata chiusura form dopo tutte le sezioni, aggiunto pulsante "Salva Tutte le Modifiche"
- **Contatti**: Aggiunto pulsante salva nella riga nuova (icona💾)
**Fix**:
1. `resources/views/admin/email-settings/index.blade.php:268` — Aggiunto `<input type="hidden" name="signature_enabled" value="0">` prima della checkbox
2. `resources/views/impostazioni/index.blade.php:603` — Stesso hidden fallback
3. `resources/views/impostazioni/index.blade.php:1633` — Aggiunto `#email-firma` al primo handler `shown.bs.tab` (il secondo handler esisteva già ma il primo no)
4. `app/Http/Controllers/Admin/EmailSettingsController.php:70-82` — Aggiunto log di warning se signature o signature_enabled non corrispondono dopo il salvataggio
### 17 Maggio 2026 - Bulk Action Email Fix
- **Problema**: Azioni massive su email mostravano popup "errore"
- **Causa**: authorizeWrite() lancia eccezione 403 HTML invece di JSON
- **Soluzione**: try-catch in bulkAction(), markRead(), toggleStar()
### 2026-06-08 — Sync email: "Nessuna configurazione email attiva" nonostante test IMAP funzioni
**Problema**: Cliccando "Sincronizza Ora" nelle impostazioni email o "Ricevi/Invia" nella pagina email, viene mostrato "Nessuna configurazione email attiva". Il test IMAP funziona correttamente.
---
**Root cause**: Stesso bug dei checkbox senza hidden fallback. Il campo `is_active` nel tab Sincronizzazione non veniva inviato quando non spuntato. Alla prima configurazione, se l'utente non visitava esplicitamente il tab Sync e attivava lo switch, `is_active` rimaneva `false` (default DB). Il test IMAP usa `EmailSetting::first()` (ignora `is_active`), mentre `syncEmails()` e `syncNow()` usano `EmailSetting::getActive()` che filtra per `is_active = true`.
(Last updated: 25 Maggio 2026 - Fix export CSV individui)
**Fix**:
1. `resources/views/admin/email-settings/index.blade.php:251` — Aggiunto `<input type="hidden" name="is_active" value="0">` prima della checkbox
2. `resources/views/impostazioni/index.blade.php:586` — Stesso hidden fallback
3. `EmailSetting::getActive()` ora riceve sempre `is_active` dal form (0 o 1) grazie all'hidden fallback
### 18 Maggio 2026 - AGENTS.md Update
- Aggiunta regola "Verifica Salvataggio" nelle Regole Operative AI
- Obbligo di verificare SEMPRE l'avvenuta riuscita dopo operazioni di modifica (store, update, delete, upload, sync)
## 2026-06-08 — Help page: aggiunto tab "Calendario" con documentazione OAuth
### 25 Maggio 2026 - IndividuoController::elimina() Method
- **Problema**: Errore 500 "Call to undefined method App\Http\Controllers\IndividuoController::elimina()" su richiesta `POST /individui/{id}/elimina`
- **Causa**: Route definita in `routes/web.php:58` ma metodo `elimina()` non implementato nel controller
- **Soluzione**: Aggiunto metodo `elimina()` in `IndividuoController` che:
- Verifica autorizzazione con `authorizeDelete('individui')`
- Riceve parametri `elimina_contatti` e `elimina_documenti` dal form
- Elimina opzionalmente i contatti collegati se richiesto
- Elimina opzionalmente i documenti collegati (con rimozione file fisico) se richiesto
- Elimina l'individuo e redirect alla lista con messaggio di successo
- **Files modificati**:
- `app/Http/Controllers/IndividuoController.php`: aggiunto metodo `elimina()` dopo `destroy()`
- **`resources/views/help/index.blade.php`**: Aggiunto tab "Calendario" nel menu laterale (dopo Google Drive) e relativo tab-pane content con:
- Requisiti (account Google, API Calendar, OAuth credentials)
- Passo 1: Google Cloud Console — abilitare Google Calendar API, creare OAuth Client ID (Web Application), redirect URI `https://developers.google.com/oauthplayground`
- Passo 2: Ottenere Refresh Token via Google OAuth Playground (Use your own OAuth credentials, scope `calendar`)
- Passo 3: Configurazione nell'app (Impostazioni → Calendario, nuova connessione Google Calendar, inserire Client ID/Secret/Refresh Token)
- Test connessione e sincronizzazione
- Nota: refresh token permanente finché non revocato
- **`resources/views/help/pdf.blade.php`**: Aggiunta sezione "3. Google Calendar" nel TOC e contenuto, rinumerazione da 4 a 8 per sezioni successive
### 25 Maggio 2026 - Fix Eliminazione Singola e Massiva
- **Problema**: L'eliminazione non veniva eseguita né per il singolo né per la mass action
- **Cause**:
1. `$request->boolean()` interpretava male i valori `'true'`/`'false'` come stringhe
2. `deleteSelected()` faceva submit multipli di form separati causando redirect concorrenti
- **Soluzione**:
1. Sostituito `$request->boolean()` con `filter_var(..., FILTER_VALIDATE_BOOLEAN)` per parsing corretto
2. Aggiunto metodo `massElimina()` in `IndividuoController` per gestire eliminazione multipla con singolo form
3. Aggiunta route `POST /individui/mass-elimina` in `routes/web.php`
4. Rifattorizzata funzione JS `deleteSelected()` per usare singolo form con array `ids[]`
- **Files modificati**:
- `app/Http/Controllers/IndividuoController.php`: corretto `elimina()`, aggiunto `massElimina()`
- `routes/web.php`: aggiunta route `individui/mass-elimina`
- `resources/views/individui/index.blade.php`: rifattorizzata `deleteSelected()`
### Differenza chiave Google Calendar vs Google Drive
- **Drive**: endpoint server `/auth/google-drive/callback` che gestisce automaticamente il callback OAuth e salva refresh token in `storage_repositories`
- **Calendar**: **NON** ha endpoint server dedicato. Refresh token ottenuto manualmente via Google OAuth Playground e incollato nel campo "Refresh Token" del form
### 25 Maggio 2026 - Fix Export CSV Individui
- **Problema**: "Esporta Tutti CSV" e "Esporta Selezionati CSV" non funzionavano
- **Cause**:
1. `exportCSV()` usava `:visible` (selettore jQuery) con `querySelectorAll` (JS nativo) che non lo supporta
2. Export client-side esportava solo righe nel DOM (pagina corrente con pagination), non tutti i record
3. Dati limitati a 5 colonne senza contatti email/telefono
- **Soluzione**:
1. Creato metodo `exportCSV()` server-side in `IndividuoController` con `StreamedResponse`
2. Export completo con: Codice, Cognome, Nome, Data Nascita, Genere, Email, Telefono, Cellulare, Indirizzo, CAP, Città, Provincia, Note
3. Email/Telefono/Cellulare recuperati dai contatti con preferenza per quelli primari
4. Supporto per export tutti o solo selezionati tramite parametro `ids[]`
5. Funzioni JS `exportCSV()` e `exportSelectedCSV()` ora puntano a `/individui/export`
- **Files modificati**:
- `app/Http/Controllers/IndividuoController.php`: aggiunto metodo `exportCSV()` con StreamedResponse
- `routes/web.php`: aggiunta route `GET /individui/export`
- `resources/views/individui/index.blade.php`: semplificate funzioni `exportCSV()` e `exportSelectedCSV()`
## 2026-06-08 — Pulsante Sync nella pagina calendario eventi
(Last updated: 25 Maggio 2026 - Report system, mass actions, gruppi deletion fix)
- **`app/Http/Controllers/CalendarioConnessioneController.php`**: Aggiunto metodo `syncAll()` che itera tutte le connessioni attive (`is_active = true`), esegue `syncEvents()` per ognuna, e restituisce JSON con risultati aggregati.
- **`routes/web.php`**: Aggiunta route `POST /calendario-connessioni/sync-all``syncAll` (name: `calendario-connessioni.sync-all`).
- **`resources/views/eventi/calendar.blade.php`**: Aggiunto pulsante "Sync" nel card-tools che:
- Invia POST AJAX a `sync-all`
- Mostra spinner durante l'operazione
- Al completamento mostra alert con riepilogo (connessioni, importati, esportati)
- Richiama `window.calendar.refetchEvents()` per aggiornare la vista FullCalendar
- Esposto `window.calendar` globalmente per accesso dall'handler sync
### 25 Maggio 2026 - Fix Eliminazione Gruppi Padre (Superadmin Only)
- **Problema**: Eliminando un gruppo padre si otteneva pagina 404. Inoltre l'eliminazione di gruppi con figli era disponibile per tutti
- **Cause**:
1. Mancato detach delle tabelle pivot (`gruppo_individuo`, `eventi_gruppi`) prima della cancellazione
2. Nessun controllo per limitare l'eliminazione di gruppi con sottogruppi al superadmin
- **Soluzione**:
1. Rifattorizzato `destroy()` in `GruppoController`: aggiunta verifica `isSuperAdmin()` per gruppi con figli
2. Creato metodo `destroyGruppo()` helper che esegue detach pivot, cancella documenti fisici e poi cancella il gruppo
3. Aggiornata vista `tree-item.blade.php`: pulsante elimina disabilitato per non-superadmin su gruppi con figli
- **Files modificati**:
- `app/Http/Controllers/GruppoController.php`: rifattorizzato `destroy()`, aggiunto `destroyGruppo()`
- `resources/views/gruppi/partials/tree-item.blade.php`: pulsante elimina condizionato a superadmin per gruppi padre
## 2026-06-08 — Fix logo non visibile su installazione remota
### 25 Maggio 2026 - Mass Actions Eventi e Mailing List
- **Problema**: Eventi e Mailing List non avevano funzionalità di eliminazione massiva
- **Soluzione**:
1. Aggiunti checkbox di selezione, pulsante "Elimina Selezionati" e modal di conferma in `eventi/index.blade.php`
2. Aggiunti checkbox di selezione, pulsante "Elimina Selezionati" e modal di conferma in `mailing-liste/index.blade.php`
3. Aggiunto metodo `massElimina()` in `EventoController` con detach di gruppi e responsabili
4. Aggiunto metodo `massElimina()` in `MailingListController` con cancellazione contatti associati
5. Aggiunte route `POST /eventi/mass-elimina` e `POST /mailing-liste/mass-elimina`
- **Files modificati**:
- `app/Http/Controllers/EventoController.php`: aggiunto `massElimina()`
- `app/Http/Controllers/MailingListController.php`: aggiunto `massElimina()`
- `routes/web.php`: aggiunte route mass-elimina per eventi e mailing-liste
- `resources/views/eventi/index.blade.php`: aggiunta selezione massiva, modal conferma, JS
- `resources/views/mailing-liste/index.blade.php`: aggiunta selezione massiva, modal conferma, JS
**Problema**: Il logo uploadato non veniva visualizzato su installazioni remote, pur salvandosi correttamente su disco.
### 25 Maggio 2026 - Rimozione Menu Utilità Viste
- **Modifica**: Rimosso dal sidebar menu la voce "Utilità Viste" (`viste.index`)
- **Nota**: Le route e i controller per le viste rimangono funzionanti, solo il link sidebar è stato rimosso temporaneamente
**Root cause**: `getLogoPath()` e `getLogoSmallPath()` in `AppSetting.php` usavano `file_exists(public_path('storage/' . $path))` per verificare l'esistenza del file prima di restituire l'URL. Questo controllo dipendeva dal symlink `public/storage → storage/app/public`. Su installazioni remote dove il symlink era assente o mal configurato, `file_exists` restituiva `false` → metodo restituiva `null` → logo non mostrato.
### 25 Maggio 2026 - Sistema Report Completo
- **Funzionalità**: Nuova pagina Report accessibile solo dal superadmin con:
1. **Panoramica database**: statistiche rapide (individui, gruppi, eventi, documenti, mailing list, contatti)
2. **12 Report predefiniti**:
- Individui per Genere
- Individui per Fascia d'Età
- Gerarchia Gruppi
- Gruppi e Membri
- Eventi del Mese
- Documenti per Tipologia
- Contatti per Tipo
- Dettaglio Mailing List
- Individui senza Contatti
- Individui senza Gruppo
- Eventi per Gruppo
- Documenti Orfani
3. **Report personalizzati**: creazione di report custom con selezione tipo dato, colonne, ordinamento e limite
4. **Export CSV**: ogni report può essere esportato in CSV
- **Files creati**:
- `app/Http/Controllers/ReportController.php`: controller completo con 12 report + custom
- `app/Models/ReportCustom.php`: modello per report personalizzati
- `database/migrations/2026_05_25_000001_create_report_custom_table.php`: migration tabella report_custom
- `resources/views/report/index.blade.php`: pagina principale report con card e form creazione
- `resources/views/report/result.blade.php`: pagina visualizzazione risultati report
- **Routes aggiunte**:
- `GET /report` → report.index
- `GET /report/run` → report.run
- `GET /report/export` → report.export
- `GET /report/custom/{id}` → report.run-custom
- `POST /report/custom` → report.store-custom
- `DELETE /report/custom/{id}` → report.destroy-custom
- **Sidebar aggiornata**: link "Report" ora punta a `route('report.index')` con evidenziazione active
**Fix**:
1. **`app/Models/AppSetting.php`**: Sostituito `file_exists(public_path('storage/' . $path))` con `Storage::disk('public')->exists($path)` e `asset('storage/' . $path)` con `Storage::disk('public')->url($path)`. Il controllo ora avviene direttamente su `storage/app/public/` (disk root), bypassando il symlink.
2. **`app/Http/Controllers/ImpostazioniController.php`**: In `uploadLogo()`, aggiunta creazione automatica del symlink `public/storage → storage/app/public` se mancante, per garantire che il browser possa servire l'immagine.
(Last updated: 25 Maggio 2026 - Fix sidebar @endif orfano, verifica completa sintassi)
### 25 Maggio 2026 - Fix Sidebar Errore Sintassi
- **Problema**: Errore "syntax error, unexpected token 'endif'" nella dashboard
- **Causa**: Dopo aver rimosso il blocco `@if(Auth::user()->isSuperAdmin())` dalla sidebar per sostituirlo con `@if(Auth::user()->canAccess('report'))`, erano rimasti un `@endif` orfano (linea 237) e un `</li>` extra (linea 239)
- **Soluzione**:
1. Rimosso `@endif` orfano
2. Rimosso `</li>` extra
3. Aggiunto nuovo `@if(Auth::user()->isSuperAdmin())` per proteggere i menu Impostazioni e Admin
- **Verifica completa**: Scansionati tutti i blade file del progetto - tutte le direttive `@if/@endif`, `@foreach/@endforeach`, `@php/@endphp` sono correttamente bilanciate. Compilazione viste OK.
### 25 Maggio 2026 - Accesso Report Basato su Permessi
- **Modifica**: Il modulo 'report' è stato aggiunto a `User::MODULES` come nuovo modulo permissionabile
- **Funzionalità**: Gli utenti con permesso `canAccess('report')` (livello lettura o superiore) possono accedere alla pagina Report, non più solo il superadmin
- **Files modificati**:
- `app/Models/User.php`: aggiunto 'report' a `MODULES` = ['individui', 'gruppi', 'eventi', 'documenti', 'mailing', 'viste', 'report']
- `app/Http/Controllers/ReportController.php`: sostituiti tutti i check `isSuperAdmin()` con `canAccess('report')`
- `resources/views/layouts/adminlte.blade.php`: sidebar mostra "Report" se `canAccess('report')` invece di `isSuperAdmin()`
- **Come abilitare un utente**: Dal pannello Admin → Utenti, assegnare permesso di livello ≥ 1 (Lettura) per il modulo 'report'
### 25 Maggio 2026 - Viste Personalizzate per Colonne (Individui e Gruppi)
- **Funzionalità**: Ogni utente può personalizzare quali colonne visualizzare nelle liste di Individui e Gruppi
- **Individui**:
- Colonne disponibili: Codice, Cognome, Nome, Email, Telefono
- Il controller `IndividuoController@index()` ora passa `$allColumns` e `$visibleColumns` alla vista
- Le colonne visibili sono determinate dalla `VistaReport` predefinita dell'utente
- Modal "Colonne Visibili" mostra tutte le colonne con checkbox per toggle
- Modal "Salva Vista" permette di salvare una vista con nome, colonne, e opzione predefinita
- **Gruppi**:
- Nuova vista tabella affiancata alla vista albero esistente
- Toggle "Tabella/Albero" nella card header
- Colonne disponibili: Nome, Descrizione, Diocesi, Livello, Gruppo Padre, Membri, Responsabili, Indirizzo, Città
- Le colonne visibili sono determinate dalla `VistaReport` predefinita per tipo 'gruppi'
- Salvataggio vista dedicato con route `POST /gruppi/save-vista`
- Eliminazione vista con route `DELETE /gruppi/vista/{id}`
- **Model utilizzato**: `VistaReport` (tabella `viste_report`) con campi: `user_id`, `nome`, `tipo`, `colonne_visibili` (JSON), `is_default`
- **Files modificati**:
- `app/Http/Controllers/IndividuoController.php`: `index()` passa `$allColumns` e `$visibleColumns`
- `app/Http/Controllers/GruppoController.php`: `index()` rifattorizzato per vista tabella/albero, aggiunti `saveVista()` e `deleteVista()`
- `routes/web.php`: aggiunte route `gruppi/save-vista` e `gruppi/delete-vista`
- `resources/views/individui/index.blade.php`: colonne condizionali con `@if(in_array(..., $visibleColumns))`
- `resources/views/gruppi/index.blade.php`: riscritta completamente con doppio mode (tabella/albero), modal salvataggio vista, toggle colonne
### 25 Maggio 2026 - Fix Vari Pagine (Individui, Eventi, Mailing, Documenti Upload)
- **Individui/Gruppi**: Errore `$tableColumns` non definito risolto usando `$allColumns`
- **Eventi**: Checkbox righe senza `id` impedivano il corretto funzionamento del label; aggiunto `id="row-checkbox-{{ $evento->id }}"`
- **Mailing List**: Stesso problema checkbox, corretto con `id` univoci
- **Documenti upload**: Funzione `loadUploadTargets()` chiamata ma non definita; aggiunta per popolare il dropdown target
### 25 Maggio 2026 - Fix Documento Avatar e Morph Map
- **Problema**: Caricando un documento avatar associato a un individuo, errore "Class 'individuo' not found"
- **Causa**: Il campo `visibilita_target_type` nel database conteneva nomi brevi ('individuo', 'gruppo') invece del namespace completo
- **Soluzione**:
1. Registrata `Relation::morphMap()` in `AppServiceProvider@boot()` per mappare nomi brevi a classi complete
2. Aggiornato `DocumentoController@store()` per impostare correttamente `visibilita_target_type` in base alla visibilità
- **Files modificati**:
- `app/Providers/AppServiceProvider.php`: aggiunta morphMap per individuo, gruppo, evento, mailing, associazione, federazione
- `app/Http/Controllers/DocumentoController.php`: logica esplicita per mappare visibilità → target_type
### 25 Maggio 2026 - Fix Pagina Gruppi (Tabella Pivot Inesistente)
- **Problema**: Pagina gruppi falliva con "Table 'glastree.gruppo_responsabili' doesn't exist"
- **Causa**: Il modello `Gruppo` aveva una relazione `responsabili()` che puntava a tabella pivot inesistente (la migration `2026_05_12_000003` aveva migrato a JSON `responsabile_ids`)
- **Soluzione**:
1. Rimossa relazione `responsabili()` da `Gruppo.php`
2. Rimossa eager loading `responsabili` da `GruppoController@index()`
3. Mantenuti metodi helper `getResponsabiliIds()` e `getResponsabili()` che leggono dal campo JSON
4. Corretti riferimenti in `gruppi/show.blade.php` e `individui/show.blade.php` per usare `getResponsabili()`
- **Files modificati**:
- `app/Models/Gruppo.php`: rimossa relazione `responsabili()`, mantenuti helper
- `app/Http/Controllers/GruppoController.php`: rimossa eager loading `responsabili`
- `resources/views/gruppi/show.blade.php`: corretto `$child->responsabile` → `$child->getResponsabili()->first()`
- `resources/views/individui/show.blade.php`: corretto `$gruppo->responsabile` → `$gruppo->getResponsabili()->first()`
### 25 Maggio 2026 - Fix Caricamento Documenti in Pagina Gruppi
- **Funzionalità**: Aggiunto modale "Carica Documento" nella pagina `gruppi/show` per upload diretto
- **Logica Responsabili**: Solo i membri del gruppo possono essere selezionati come responsabili
- In `create.blade.php`: il dropdown responsabili si aggiorna dinamicamente quando si aggiungono/rimuovono membri
- Funzione JS `updateResponsabiliFromMembri()` sincronizza il select responsabili con i membri aggiunti
- **Files modificati**:
- `resources/views/gruppi/show.blade.php`: aggiunto modale upload documento, alert success/error
- `resources/views/gruppi/create.blade.php`: logica JS per vincolare responsabili ai soli membri
(Last updated: 25 Maggio 2026 - Fix report eventi_calendario, colonna data)
### 25 Maggio 2026 - Fix Report Eventi Calendario
- **Problema**: Report "Eventi del Mese" falliva con "Unknown column 'data_inizio' in 'WHERE'"
- **Causa**: Il report usava `data_inizio` che non esiste nella tabella `eventi`. La colonna corretta è `data_specifica`
- **Soluzione**: Sostituito `data_inizio` con `data_specifica` in `reportEventiCalendario()` e `reportEventiPerGruppo()`
- **Files modificati**:
- `app/Http/Controllers/ReportController.php`: righe 389-397 e 532, corretto nome colonna
(Last updated: 25 Maggio 2026 - Fix report eventi_calendario, colonna data)
### 25 Maggio 2026 - Fix Gruppi Table Hierarchy
- **Problema**: La visualizzazione a tabella dei gruppi non mostrava correttamente la gerarchia padre-figlio
- **Soluzione**:
- Aggiunto metodo `sortGruppiHierarchically()` in `GruppoController` per ordinare i gruppi in modo gerarchico
- La tabella ora mostra prima i gruppi root, poi i loro figli in ordine annidato
- Aggiunta eager loading `avatar` per supportare il futuro avatar dei gruppi
- **Files modificati**:
- `app/Http/Controllers/GruppoController.php`: aggiunto `sortGruppiHierarchically()`, `addChildrenRecursive()`, aggiunto 'avatar' a eager loading
### 25 Maggio 2026 - Group Avatar Support
- **Funzionalità**: I gruppi ora possono avere un avatar/logo come gli individui
- **Implementazione**:
- Aggiunto metodo `avatar()` in `Gruppo.php` (relazione HasOne a Documento con tipologia 'avatar')
- Aggiunto accessor `getAvatarUrlAttribute()` per ottenere l'URL dell'avatar
- Eager loading `avatar` aggiunto in `GruppoController@index()`
- View `gruppi/index.blade.php` aggiornata per mostrare l'avatar nella tabella
- View `gruppi/show.blade.php` aggiornata per mostrare l'avatar del gruppo
- View `gruppi/create.blade.php` e `edit.blade.php` aggiornate per permettere upload avatar
- **Files modificati**:
- `app/Models/Gruppo.php`: aggiunto `avatar()`, `getAvatarUrlAttribute()`
- `app/Http/Controllers/GruppoController.php`: aggiunto eager loading `avatar`
- `resources/views/gruppi/index.blade.php`: colonna avatar nella tabella
- `resources/views/gruppi/show.blade.php`: avatar nella header card
- `resources/views/gruppi/create.blade.php` e `edit.blade.php`: upload avatar
### 26 Maggio 2026 - Event Types System (Tipologie Eventi)
- **Nuova tabella**: `tipologie_eventi` con migration + seed dati default (catechesi, liturgia, animazione, formazione, incontro, riunione, festa, altro)
- **Nuovo modello**: `App\Models\TipologiaEvento` — identico pattern a `TipologiaDocumento` e `Ruolo`:
- `$fillable`: nome, descrizione, ordine, attiva
- Metodi: `eventi()` (hasMany), `attive()`, `opzioni()`
- FK via `nome` (string) → `eventi.tipo_evento` (stesso pattern Documento.tipologia → TipologiaDocumento.nome)
- **Admin CRUD**: 4 nuovi metodi in `ImpostazioniController`: `tipologieEventiStore()`, `tipologieEventiUpdate()`, `tipologieEventiDestroy()`, `tipologieEventiReorder()`
- **Routes** (5 nuove): `/impostazioni/tipologie-eventi/*`
- **Impostazioni view**: Nuovo tab `#tipologie-eventi` con tabella sortabile, modali add/edit, stessi pattern esistenti
- **Sidebar**: Nuovo link "Tipologie Eventi" sotto Impostazioni (solo superadmin)
- **Evento model**: Aggiunta relazione `tipologiaEvento()` (BelongsTo via 'nome')
- **EventoController**: Validazione cambiata da `nullable|string|max:100` a `nullable|exists:tipologie_eventi,nome` in store() e update(); create/edit passano `$tipologieEventi` alla view
- **Views eventi**:
- `create.blade.php`: Dropdown "Tipo Evento" dopo Descrizione Breve
- `edit.blade.php`: Text libero → dropdown; fallback per vecchi valori non presenti
- `show.blade.php`: Badge "Tipologia" nella sezione Data e Orario; colonna rinominata "Ricorrenza"
- `index.blade.php`: Nuova colonna sortabile "Tipologia" con badge; colonna "Tipo" → "Ricorrenza"; colspan 8→9
- **Files creati**:
- `database/migrations/2026_05_26_000001_create_tipologie_eventi_table.php`
- `app/Models/TipologiaEvento.php`
- **Files modificati**:
- `app/Http/Controllers/ImpostazioniController.php`
- `app/Http/Controllers/EventoController.php`
- `app/Models/Evento.php`
- `routes/web.php`
- `resources/views/impostazioni/index.blade.php`
- `resources/views/layouts/adminlte.blade.php`
- `resources/views/eventi/create.blade.php`
- `resources/views/eventi/edit.blade.php`
- `resources/views/eventi/show.blade.php`
- `resources/views/eventi/index.blade.php`
(Last updated: 26 Maggio 2026 - Event Types system)
## Prossimi Passi
- *(nessuno — in attesa di nuove richieste)*
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\BackupService;
use Illuminate\Console\Command;
class BackupRunCommand extends Command
{
protected $signature = 'backup:run
{--filename= : Custom filename prefix for the backup file}
{--no-files : Exclude uploaded files from backup}
{--no-env : Exclude .env from backup}';
protected $description = 'Esegue un backup completo del database, file e configurazione';
private BackupService $backupService;
public function __construct(BackupService $backupService)
{
parent::__construct();
$this->backupService = $backupService;
}
public function handle(): int
{
$this->info('Avvio backup...');
if ($this->option('no-files')) {
$this->backupService->saveConfig(['backup_include_files' => false]);
$this->warn('Files esclusi dal backup.');
}
if ($this->option('no-env')) {
$this->backupService->saveConfig(['backup_include_env' => false]);
$this->warn('.env escluso dal backup.');
}
$result = $this->backupService->run();
if ($result['success']) {
$this->info(' Backup completato con successo!');
$this->line('File: ' . $result['filename']);
$this->line('Dimensione: ' . $result['size_formatted']);
$this->line('Percorso: ' . storage_path('app/backups/' . $result['filename']));
return Command::SUCCESS;
}
$this->error(' Backup fallito: ' . ($result['message'] ?? 'Errore sconosciuto'));
return Command::FAILURE;
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\EmailSetting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class DiagnoseEmail extends Command
{
protected $signature = 'diagnose:email';
protected $description = 'Diagnostica configurazione email e credenziali';
public function handle(): int
{
$this->info('=== Diagnostica Email Settings ===');
$this->newLine();
// 1. APP_KEY
$this->section('APP_KEY');
$appKey = config('app.key');
if ($appKey && str_starts_with($appKey, 'base64:')) {
$this->line('APP_KEY: presente e valida ✓');
} elseif ($appKey) {
$this->warn('APP_KEY: presente ma non in formato base64 (potrebbe funzionare lo stesso)');
} else {
$this->error('APP_KEY: NON IMPOSTATA — crittografia password non funzionerà!');
}
// 2. Tabella email_settings
$this->newLine();
$this->section('Tabella email_settings');
if (Schema::hasTable('email_settings')) {
$this->line('Tabella email_settings: esiste ✓');
$columns = Schema::getColumnListing('email_settings');
$this->line('Colonne: ' . implode(', ', $columns));
$required = ['imap_host', 'imap_username', 'imap_password', 'email_address'];
$missing = array_diff($required, $columns);
if ($missing) {
$this->error('Colonne mancanti: ' . implode(', ', $missing));
} else {
$this->line('Colonne richieste presenti ✓');
}
} else {
$this->error('Tabella email_settings NON ESISTE! Esegui: php artisan migrate');
}
// 3. Record email_settings
$this->newLine();
$this->section('Record email_settings');
$settings = EmailSetting::first();
if ($settings) {
$this->line('Record trovato (ID: ' . $settings->id . ')');
$this->line(' imap_host: ' . ($settings->imap_host ?: 'VUOTO'));
$this->line(' imap_username: ' . ($settings->imap_username ?: 'VUOTO'));
$this->line(' imap_password: ' . ($settings->imap_password ? '[CRITTATO]' : 'VUOTO'));
$this->line(' email_address: ' . ($settings->email_address ?: 'VUOTO'));
$this->line(' smtp_host: ' . ($settings->smtp_host ?: 'VUOTO'));
// Prova decrittazione
if ($settings->imap_password) {
try {
$decrypted = Crypt::decryptString($settings->imap_password);
$this->line(' -> password IMAP decrittata: ' . (strlen($decrypted) > 0 ? 'OK (' . strlen($decrypted) . ' caratteri)' : 'VUOTA'));
} catch (\Exception $e) {
$this->error(' -> ERRORE decrittazione password IMAP: ' . $e->getMessage());
$this->warn(' Causa probabile: APP_KEY diversa da quando è stata salvata.');
}
}
} else {
$this->warn('NESSUN record in email_settings — nessuna configurazione salvata.');
$this->line('Vai su /impostazioni#email-imap, compila e clicca "Salva Impostazioni Email"');
}
// 4. Tabella sender_accounts
$this->newLine();
$this->section('Tabella sender_accounts');
if (Schema::hasTable('sender_accounts')) {
$this->line('Tabella sender_accounts: esiste ✓');
$count = DB::table('sender_accounts')->count();
$this->line('Record presenti: ' . $count);
} else {
$this->error('Tabella sender_accounts NON ESISTE!');
}
// 5. Log recenti
$this->newLine();
$this->section('Errori recenti (storage/logs/laravel.log)');
$logPath = storage_path('logs/laravel.log');
if (file_exists($logPath)) {
$lines = $this->getLastErrorLines($logPath, 20);
if ($lines) {
foreach ($lines as $line) {
$this->line($line);
}
} else {
$this->line('Nessun errore recente trovato.');
}
} else {
$this->warn('File di log non trovato: ' . $logPath);
}
// 6. Estensioni PHP
$this->newLine();
$this->section('Estensioni PHP');
$requiredExts = ['openssl', 'json', 'mbstring', 'pdo', 'fileinfo'];
foreach ($requiredExts as $ext) {
if (extension_loaded($ext)) {
$this->line($ext . ': OK ✓');
} else {
$this->error($ext . ': MANCANTE!');
}
}
$this->newLine();
$this->info('=== Diagnostica completata ===');
return self::SUCCESS;
}
private function section(string $title): void
{
$this->line("--- {$title} ---");
}
private function getLastErrorLines(string $path, int $maxLines): array
{
$lines = [];
$fp = fopen($path, 'r');
if (!$fp) {
return $lines;
}
fseek($fp, -1, SEEK_END);
$pos = ftell($fp);
$buffer = '';
while ($pos > 0 && count($lines) < $maxLines) {
$char = fgetc($fp);
if ($char === "\n") {
if (trim($buffer) !== '') {
$lines[] = trim($buffer);
}
$buffer = '';
} else {
$buffer = $char . $buffer;
}
$pos--;
fseek($fp, $pos);
}
if (trim($buffer) !== '') {
$lines[] = trim($buffer);
}
fclose($fp);
return $lines;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AppSetting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Storage;
class ExportHelpPdf extends Command
{
protected $signature = 'help:export-pdf {--output= : Output path for the PDF file}';
protected $description = 'Genera il PDF della guida completa';
public function handle(): int
{
$appName = AppSetting::getAppName() ?? 'Glastree';
$this->components->info('Generazione HTML della guida...');
$html = View::make('help.pdf', ['appName' => $appName])->render();
$tmpHtml = tempnam(sys_get_temp_dir(), 'help_') . '.html';
file_put_contents($tmpHtml, $html);
$output = $this->option('output');
if (!$output) {
$output = storage_path('app/help-guida.pdf');
}
$this->components->info('Conversione in PDF via Chromium...');
$cmd = sprintf(
'/usr/bin/chromium --headless --no-sandbox --disable-gpu --print-to-pdf=%s %s 2>&1',
escapeshellarg($output),
escapeshellarg('file://' . $tmpHtml)
);
exec($cmd, $execOutput, $exitCode);
unlink($tmpHtml);
if ($exitCode !== 0) {
$this->components->error('Errore Chromium: ' . implode("\n", $execOutput));
return Command::FAILURE;
}
$size = filesize($output);
$this->components->success(sprintf(
'PDF generato: %s (%.2f MB)',
$output,
$size / 1024 / 1024
));
return Command::SUCCESS;
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class ResetUserPassword extends Command
{
protected $signature = 'user:reset-password
{email? : Email dell\'utente}
{--password= : Nuova password (se omessa, richiesta interattiva)}';
protected $description = 'Cambia la password di un utente tramite email';
public function handle(): int
{
$email = $this->argument('email');
if (empty($email)) {
$email = $this->ask('Email dell\'utente');
}
$user = User::where('email', $email)->first();
if ($user === null) {
$this->error("Nessun utente trovato con email: {$email}");
return self::FAILURE;
}
$this->line("Utente trovato: {$user->name} ({$user->email})");
$password = $this->option('password');
if (empty($password)) {
$password = $this->secret('Nuova password');
$confirm = $this->secret('Conferma nuova password');
if ($password !== $confirm) {
$this->error('Le password non corrispondono');
return self::FAILURE;
}
}
$validator = Validator::make(
['password' => $password],
['password' => ['required', 'string', 'min:12']]
);
if ($validator->fails()) {
$this->error('Password troppo corta: minimo 12 caratteri');
return self::FAILURE;
}
$user->password = Hash::make($password);
$user->save();
if ($user->wasChanged('password')) {
$this->info("Password aggiornata con successo per {$user->email}");
return self::SUCCESS;
}
$this->error('Errore durante l\'aggiornamento della password');
return self::FAILURE;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
class SyncUserPermissions extends Command
{
protected $signature = 'users:sync-permissions';
protected $description = 'Sync all users permissions with MODULES, adding missing keys with default 0';
public function handle(): int
{
$count = 0;
foreach (User::all() as $user) {
$permissions = $user->permissions ?? [];
$changed = false;
foreach (User::MODULES as $module) {
if (!array_key_exists($module, $permissions)) {
$permissions[$module] = User::LEVEL_NONE;
$changed = true;
}
}
if ($changed) {
$user->permissions = $permissions;
$user->save();
$this->info("Synced permissions for user ID {$user->id} ({$user->email})");
$count++;
}
}
$this->info("Done. {$count} users updated.");
return self::SUCCESS;
}
}
+200
View File
@@ -0,0 +1,200 @@
<?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'],
],
'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'],
],
'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'],
],
'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'],
],
'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'));
}
public function destroy(Request $request)
{
$query = ActivityLog::query();
if ($request->filled('user_id')) {
$query->where('user_id', $request->user_id);
}
if ($request->filled('module')) {
$query->where('module', $request->module);
}
if ($request->filled('action')) {
$query->where('action', $request->action);
}
if ($request->filled('from')) {
$query->whereDate('created_at', '>=', $request->from);
}
if ($request->filled('to')) {
$query->whereDate('created_at', '<=', $request->to);
}
$count = $query->count();
$query->delete();
return redirect()->route('admin.activity-logs.index')
->with('success', "Eliminati {$count} log.");
}
}
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AppSetting;
use App\Services\BackupService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BackupController extends Controller
{
private BackupService $backupService;
public function __construct(BackupService $backupService)
{
$this->backupService = $backupService;
}
public function index(): View
{
$backups = $this->backupService->list();
$config = $this->backupService->getConfig();
return view('admin.backup.index', compact('backups', 'config'));
}
public function run(): RedirectResponse
{
$result = $this->backupService->run();
if ($result['success']) {
return redirect()->route('admin.backup.index')
->with('success', 'Backup completato: ' . ($result['filename'] ?? '') . ' (' . ($result['size_formatted'] ?? '') . ')');
}
return redirect()->route('admin.backup.index')
->with('error', $result['message'] ?? 'Errore durante il backup.');
}
public function download(string $filename)
{
$filepath = $this->backupService->download($filename);
if (!$filepath) {
return redirect()->route('admin.backup.index')
->with('error', 'File di backup non trovato.');
}
return response()->download($filepath)->deleteFileAfterSend(false);
}
public function destroy(string $filename): RedirectResponse
{
if ($this->backupService->delete($filename)) {
return redirect()->route('admin.backup.index')
->with('success', 'Backup "' . $filename . '" eliminato.');
}
return redirect()->route('admin.backup.index')
->with('error', 'Impossibile eliminare il backup.');
}
public function updateConfig(Request $request): RedirectResponse
{
$validated = $request->validate([
'backup_path' => 'nullable|string|max:255',
'backup_retention_days' => 'nullable|integer|min:1|max:365',
'backup_include_files' => 'nullable|boolean',
'backup_include_env' => 'nullable|boolean',
]);
$this->backupService->saveConfig($validated);
return redirect()->route('admin.backup.index')
->with('success', 'Configurazione backup salvata.');
}
public function toggleAuto(): RedirectResponse
{
$enabled = !AppSetting::getSetting('backup_auto_enabled', false);
$setting = AppSetting::first() ?? new AppSetting();
$setting->backup_auto_enabled = $enabled;
$setting->save();
$msg = $enabled ? 'Backup automatico abilitato.' : 'Backup automatico disabilitato.';
return redirect()->route('admin.backup.index')->with('success', $msg);
}
public function saveAutoConfig(Request $request): RedirectResponse
{
$validated = $request->validate([
'backup_auto_enabled' => 'nullable|boolean',
'backup_auto_frequency' => 'nullable|in:daily,weekly,monthly',
'backup_auto_hour' => 'nullable|integer|min:0|max:23',
]);
$setting = AppSetting::first() ?? new AppSetting();
$setting->backup_auto_enabled = !empty($validated['backup_auto_enabled']);
$setting->backup_auto_frequency = $validated['backup_auto_frequency'] ?? 'daily';
$setting->backup_auto_hour = $validated['backup_auto_hour'] ?? 3;
$setting->save();
return redirect()->route('admin.backup.index')
->with('success', 'Configurazione backup automatico salvata.');
}
public function downloadMigrationScript(): JsonResponse
{
$script = view('admin.backup.migration-script')->render();
return response()->json(['script' => $script]);
}
}
@@ -3,20 +3,28 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\EmailAttachment;
use App\Models\EmailFolder;
use App\Models\EmailSetting;
use App\Models\SenderAccount;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
class EmailSettingsController extends Controller
{
public function index()
{
$this->authorizeWrite('settings');
$settings = EmailSetting::first() ?? new EmailSetting();
return view('admin.email-settings.index', compact('settings'));
$senderAccounts = SenderAccount::orderBy('email_address')->get();
return view('admin.email-settings.index', compact('settings', 'senderAccounts'));
}
public function save(Request $request)
{
$this->authorizeWrite('settings');
$validated = $request->validate([
'imap_host' => 'required|string|max:255',
'imap_port' => 'required|integer|min:1|max:65535',
@@ -26,11 +34,15 @@ class EmailSettingsController extends Controller
'smtp_host' => 'nullable|string|max:255',
'smtp_port' => 'nullable|integer|min:1|max:65535',
'smtp_encryption' => 'nullable|in:ssl,tls,none',
'smtp_username' => 'nullable|string|max:255',
'smtp_password' => 'nullable|string',
'email_address' => 'required|email',
'email_name' => 'nullable|string|max:255',
'reply_to' => 'nullable|email',
'sync_interval_minutes' => 'nullable|integer|min:1|max:60',
'is_active' => 'boolean',
'signature' => 'nullable|string',
'signature_enabled' => 'boolean',
]);
if (!empty($validated['imap_password'])) {
@@ -54,6 +66,24 @@ class EmailSettingsController extends Controller
if ($verify &&
$verify->imap_host === $validated['imap_host'] &&
$verify->email_address === $validated['email_address']) {
$signatureMatch = !array_key_exists('signature', $validated) || $verify->signature === $validated['signature'];
$signatureEnabledMatch = !array_key_exists('signature_enabled', $validated) || (bool)$verify->signature_enabled === (bool)$validated['signature_enabled'];
if (!$signatureMatch) {
\Illuminate\Support\Facades\Log::warning('Signature mismatch after save', [
'expected' => $validated['signature'] ?? null,
'actual' => $verify->signature,
]);
}
if (!$signatureEnabledMatch) {
\Illuminate\Support\Facades\Log::warning('Signature enabled mismatch after save', [
'expected' => $validated['signature_enabled'] ?? null,
'actual' => $verify->signature_enabled,
]);
}
return back()->with('success', 'Impostazioni email salvate e verificate nel database.');
} else {
return back()->with('error', 'Errore nel salvataggio: i dati non sono stati salvati correttamente.');
@@ -62,6 +92,7 @@ class EmailSettingsController extends Controller
public function testConnection(Request $request)
{
$this->authorizeWrite('settings');
$settings = EmailSetting::first();
if (!$settings) {
@@ -87,6 +118,7 @@ class EmailSettingsController extends Controller
public function testSmtp(Request $request)
{
$this->authorizeWrite('settings');
$request->validate([
'email' => 'required|email',
]);
@@ -164,6 +196,7 @@ class EmailSettingsController extends Controller
public function syncNow()
{
$this->authorizeWrite('settings');
$settings = EmailSetting::getActive();
if (!$settings) {
@@ -178,4 +211,119 @@ class EmailSettingsController extends Controller
return back()->with('error', 'Errore sync: ' . $e->getMessage());
}
}
public function senderStore(Request $request)
{
$this->authorizeWrite('settings');
$data = $request->validate([
'email_address' => 'required|email|max:255|unique:sender_accounts,email_address',
'email_name' => 'nullable|string|max:255',
'smtp_host' => 'nullable|string|max:255',
'smtp_port' => 'nullable|integer|min:1|max:65535',
'smtp_encryption' => 'nullable|in:tls,ssl,none',
'smtp_username' => 'nullable|string|max:255',
'smtp_password' => 'nullable|string',
'reply_to' => 'nullable|email|max:255',
'verify_email' => 'nullable|email|max:255',
'note' => 'nullable|string|max:500',
'is_active' => 'boolean',
]);
if (!empty($data['smtp_password'])) {
$data['smtp_password'] = Crypt::encryptString($data['smtp_password']);
} else {
unset($data['smtp_password']);
}
$sender = SenderAccount::create($data);
return back()->with('success', 'Mittente "' . $sender->email_address . '" creato con successo.');
}
public function senderUpdate(Request $request, int $id)
{
$this->authorizeWrite('settings');
$sender = SenderAccount::findOrFail($id);
$data = $request->validate([
'email_address' => 'required|email|max:255|unique:sender_accounts,email_address,' . $id,
'email_name' => 'nullable|string|max:255',
'smtp_host' => 'nullable|string|max:255',
'smtp_port' => 'nullable|integer|min:1|max:65535',
'smtp_encryption' => 'nullable|in:tls,ssl,none',
'smtp_username' => 'nullable|string|max:255',
'smtp_password' => 'nullable|string',
'reply_to' => 'nullable|email|max:255',
'verify_email' => 'nullable|email|max:255',
'note' => 'nullable|string|max:500',
'is_active' => 'boolean',
]);
if (!empty($data['smtp_password'])) {
$data['smtp_password'] = Crypt::encryptString($data['smtp_password']);
} else {
unset($data['smtp_password']);
}
$sender->update($data);
return back()->with('success', 'Mittente "' . $sender->email_address . '" aggiornato.');
}
public function senderDestroy(int $id)
{
$this->authorizeDelete('settings');
$sender = SenderAccount::findOrFail($id);
$sender->delete();
if (request()->expectsJson()) {
return response()->json(['success' => true, 'message' => 'Mittente eliminato.']);
}
return back()->with('success', 'Mittente eliminato.');
}
public function destroy(): RedirectResponse
{
$this->authorizeDelete('settings');
EmailAttachment::chunk(100, function ($attachments) {
foreach ($attachments as $attachment) {
if ($attachment->file_path && Storage::exists($attachment->file_path)) {
Storage::delete($attachment->file_path);
}
}
});
EmailFolder::whereIn('type', ['inbox', 'sent', 'drafts', 'trash', 'archive', 'starred'])->each(function ($folder) {
$folder->delete();
});
$settings = EmailSetting::first();
if ($settings) {
$settings->delete();
}
return redirect('/impostazioni/email')->with('success', 'Configurazione email eliminata. Messaggi, cartelle e allegati rimossi completamente.');
}
public function senderTestSmtp(Request $request, int $id)
{
$this->authorizeWrite('settings');
$request->validate(['email' => 'required|email']);
$sender = SenderAccount::findOrFail($id);
try {
$sender->sendEmail(
$request->email,
'Test Configurazione SMTP - ' . config('app.name'),
'Test email da ' . config('app.name') . ' - Configurazione SMTP mittente "' . $sender->email_address . '" funziona!'
);
return response()->json(['success' => true, 'message' => 'Email di test inviata a ' . $request->email]);
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => 'Errore invio: ' . $e->getMessage()]);
}
}
}
+54 -57
View File
@@ -4,11 +4,14 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\EmailSetting;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Symfony\Component\Mime\Address;
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();
ActivityLog::logLogin();
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)
{
Auth::logout();
@@ -105,4 +53,53 @@ class AuthController extends Controller
$request->session()->regenerateToken();
return redirect('/');
}
}
public function showForgotForm()
{
return view('auth.forgot-password');
}
public function sendResetLink(Request $request)
{
$request->validate(['email' => 'required|email']);
$user = User::where('email', $request->email)->first();
if (!$user) {
return back()->withErrors(['email' => 'Nessun account trovato con questa email.']);
}
$token = Password::broker()->createToken($user);
$user->sendPasswordResetNotification($token);
return back()->with('success', 'Ti abbiamo inviato un link per il reset della password via email.');
}
public function showResetForm(string $token)
{
return view('auth.reset-password', ['token' => $token]);
}
public function reset(Request $request)
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => 'required|string|min:8|confirmed',
]);
$status = Password::broker()->reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) {
$user->forceFill([
'password' => Hash::make($password),
])->save();
}
);
if ($status === Password::PASSWORD_RESET) {
return redirect()->route('login')->with('success', 'Password reimpostata con successo. Accedi con la nuova password.');
}
return back()->withErrors(['email' => [__($status)]]);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class ProfileController extends Controller
{
public function show()
{
return view('auth.profile');
}
public function updatePassword(Request $request)
{
$request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(8)],
]);
$request->user()->update([
'password' => Hash::make($request->password),
]);
return back()->with('success', 'Password aggiornata con successo.');
}
}
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\CalendarioConnessione;
use App\Services\CalDavService;
use App\Services\GoogleCalendarSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CalendarioConnessioneController extends Controller
{
public function store(Request $request): RedirectResponse
{
$this->authorizeWrite('settings');
$data = $request->validate([
'nome' => 'required|string|max:255',
'tipo' => 'required|in:google_calendar,caldav',
'sync_direction' => 'nullable|in:import,export,bidirectional',
'sync_interval_minutes' => 'nullable|integer|min:5|max:1440',
'is_active' => 'boolean',
'config' => 'nullable|array',
]);
$maxOrdine = CalendarioConnessione::max('ordine') ?? 0;
$connessione = new CalendarioConnessione();
$connessione->nome = $data['nome'];
$connessione->tipo = $data['tipo'];
$connessione->sync_direction = $data['sync_direction'] ?? 'bidirectional';
$connessione->sync_interval_minutes = $data['sync_interval_minutes'] ?? 60;
$connessione->is_active = $request->boolean('is_active', true);
$connessione->stato = 'disconnesso';
$connessione->ordine = $maxOrdine + 1;
$connessione->encryptAndSetConfig($data['config'] ?? []);
$connessione->save();
return redirect('/impostazioni#calendario')->with('success', 'Connessione calendario "' . $connessione->nome . '" creata.');
}
public function update(Request $request, int $id): RedirectResponse
{
$this->authorizeWrite('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$data = $request->validate([
'nome' => 'required|string|max:255',
'tipo' => 'required|in:google_calendar,caldav',
'sync_direction' => 'nullable|in:import,export,bidirectional',
'sync_interval_minutes' => 'nullable|integer|min:5|max:1440',
'is_active' => 'boolean',
'config' => 'nullable|array',
]);
$connessione->nome = $data['nome'];
$connessione->tipo = $data['tipo'];
$connessione->sync_direction = $data['sync_direction'] ?? 'bidirectional';
$connessione->sync_interval_minutes = $data['sync_interval_minutes'] ?? 60;
$connessione->is_active = $request->boolean('is_active', true);
if (!empty($data['config'])) {
$connessione->encryptAndSetConfig($data['config']);
}
$connessione->save();
return redirect('/impostazioni#calendario')->with('success', 'Connessione calendario "' . $connessione->nome . '" aggiornata.');
}
public function destroy(int $id): JsonResponse
{
$this->authorizeDelete('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$connessione->delete();
return response()->json(['success' => true, 'message' => 'Connessione calendario eliminata.']);
}
public function test(int $id): JsonResponse
{
$this->authorizeWrite('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$result = match ($connessione->tipo) {
'caldav' => app(CalDavService::class)->testConnection($connessione),
'google_calendar' => app(GoogleCalendarSyncService::class)->testConnection($connessione),
default => ['success' => false, 'message' => 'Tipo sconosciuto'],
};
if ($result['success']) {
$connessione->update(['stato' => 'connesso', 'last_error_at' => null, 'last_error_message' => null]);
} else {
$connessione->update(['stato' => 'errore', 'last_error_at' => now(), 'last_error_message' => $result['message'] ?? 'Errore sconosciuto']);
}
return response()->json($result);
}
public function sync(int $id): RedirectResponse
{
$this->authorizeWrite('settings');
$connessione = CalendarioConnessione::findOrFail($id);
$result = match ($connessione->tipo) {
'caldav' => app(CalDavService::class)->syncEvents($connessione),
'google_calendar' => app(GoogleCalendarSyncService::class)->syncEvents($connessione),
default => ['success' => false, 'imported' => 0, 'exported' => 0, 'errors' => ['Tipo sconosciuto']],
};
$connessione->update(['last_sync_at' => now()]);
if ($result['success']) {
$msg = 'Sincronizzazione completata: ' . $result['imported'] . ' importati, ' . $result['exported'] . ' esportati.';
$connessione->update(['stato' => 'connesso']);
return redirect('/impostazioni#calendario')->with('success', $msg);
}
$errorMsg = implode('; ', $result['errors']);
$connessione->update(['stato' => 'errore', 'last_error_at' => now(), 'last_error_message' => $errorMsg]);
return redirect('/impostazioni#calendario')->with('error', 'Errore sync: ' . $errorMsg);
}
public function syncAll(): JsonResponse
{
$this->authorizeWrite('settings');
$connections = CalendarioConnessione::where('is_active', true)->get();
$results = [];
foreach ($connections as $connessione) {
$result = match ($connessione->tipo) {
'caldav' => app(CalDavService::class)->syncEvents($connessione),
'google_calendar' => app(GoogleCalendarSyncService::class)->syncEvents($connessione),
default => ['success' => false, 'imported' => 0, 'exported' => 0, 'errors' => ['Tipo sconosciuto']],
};
$connessione->update(['last_sync_at' => now()]);
if ($result['success']) {
$connessione->update(['stato' => 'connesso']);
} else {
$errorMsg = implode('; ', $result['errors']);
$connessione->update(['stato' => 'errore', 'last_error_at' => now(), 'last_error_message' => $errorMsg]);
}
$results[] = [
'id' => $connessione->id,
'nome' => $connessione->nome,
'success' => $result['success'],
'imported' => $result['imported'],
'exported' => $result['exported'],
'errors' => $result['errors'],
];
}
$totalImported = array_sum(array_column($results, 'imported'));
$totalExported = array_sum(array_column($results, 'exported'));
$hasErrors = in_array(false, array_column($results, 'success'), true);
return response()->json([
'success' => !$hasErrors,
'results' => $results,
'total_imported' => $totalImported,
'total_exported' => $totalExported,
'connections' => count($results),
]);
}
public function reorder(Request $request): JsonResponse
{
$this->authorizeWrite('settings');
$order = $request->input('order', []);
foreach ($order as $index => $id) {
CalendarioConnessione::where('id', $id)->update(['ordine' => $index]);
}
return response()->json(['success' => true]);
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\DocumentoCartella;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DocumentoCartellaController extends Controller
{
public function store(Request $request): JsonResponse
{
$this->authorizeWrite('documenti');
$data = $request->validate([
'nome' => 'required|string|max:255',
'parent_id' => 'nullable|integer|exists:documenti_cartelle,id',
]);
$cartella = DocumentoCartella::create([
'nome' => $data['nome'],
'parent_id' => $data['parent_id'] ?? null,
]);
return response()->json([
'success' => true,
'cartella' => $cartella,
]);
}
public function update(Request $request, int $id): JsonResponse
{
$this->authorizeWrite('documenti');
$cartella = DocumentoCartella::findOrFail($id);
$data = $request->validate([
'nome' => 'required|string|max:255',
'parent_id' => 'nullable|integer|exists:documenti_cartelle,id',
]);
$updateData = ['nome' => $data['nome']];
if (array_key_exists('parent_id', $data)) {
if ((int) $data['parent_id'] === $id) {
return response()->json(['success' => false, 'message' => 'Una cartella non può essere spostata dentro se stessa.'], 422);
}
$updateData['parent_id'] = $data['parent_id'];
}
$cartella->update($updateData);
return response()->json(['success' => true]);
}
public function destroy(int $id): JsonResponse
{
$this->authorizeDelete('documenti');
$cartella = DocumentoCartella::findOrFail($id);
$documentCount = $cartella->documenti()->count();
if ($documentCount > 0) {
return response()->json([
'success' => false,
'message' => "Impossibile eliminare: {$documentCount} documento(i) presenti nella cartella. Sposta o elimina prima i documenti.",
], 409);
}
$childrenCount = $cartella->children()->count();
if ($childrenCount > 0) {
return response()->json([
'success' => false,
'message' => "Impossibile eliminare: {$childrenCount} sottocartella(e) presenti. Elimina prima le sottocartelle.",
], 409);
}
$cartella->delete();
return response()->json(['success' => true]);
}
public function breadcrumb(int $id): JsonResponse
{
$cartella = DocumentoCartella::with('parent')->findOrFail($id);
$ancestors = $cartella->ancestors();
$items = [];
$items[] = ['id' => null, 'nome' => 'Tutti i documenti'];
foreach ($ancestors as $anc) {
$items[] = ['id' => $anc->id, 'nome' => $anc->nome];
}
$items[] = ['id' => $cartella->id, 'nome' => $cartella->nome];
return response()->json(['items' => $items]);
}
}
+319 -38
View File
@@ -1,23 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\AppSetting;
use App\Models\Documento;
use App\Models\DocumentoCartella;
use App\Models\Individuo;
use App\Models\Gruppo;
use App\Models\Evento;
use App\Models\StorageRepository;
use App\Models\TipologiaDocumento;
use App\Services\StorageRepositoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
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)
{
$this->authorizeRead('documenti');
$documenti = Documento::with(['user', 'target'])
->orderByDesc('created_at')
->paginate(20);
$folderId = $request->get('folder_id');
$repoId = $request->get('repository_id');
$query = Documento::with(['user', 'target', 'cartella', 'repository'])
->orderByDesc('created_at');
if ($repoId) {
$query->where('repository_id', $repoId);
} elseif ($folderId) {
$query->where('cartella_id', $folderId);
} else {
$query->whereNull('repository_id');
}
$documenti = $query->paginate(20);
$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();
$gruppi = Gruppo::orderBy('nome')->get();
@@ -25,7 +75,22 @@ class DocumentoController extends Controller
$mailingLists = \App\Models\MailingList::orderBy('nome')->get();
$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]),
]);
return view('documenti.index', compact(
'documenti', 'cartelle', 'currentFolder', 'breadcrumb', 'sottoCartelle',
'currentRepo', 'repositories',
'individui', 'gruppi', 'eventi', 'mailingLists', 'tipologie',
'cartelleMoveOptions'
));
}
public function create()
@@ -37,6 +102,7 @@ class DocumentoController extends Controller
$tipologie = TipologiaDocumento::attive();
return view('documenti.create', compact('individui', 'gruppi', 'eventi', 'tipologie'));
}
public function edit($documento)
{
$this->authorizeWrite('documenti');
@@ -132,10 +198,33 @@ class DocumentoController extends Controller
'visibilita' => 'required|in:pubblico,individuo,gruppo',
'visibilita_target_id' => 'nullable|integer',
'visibilita_target_type' => 'nullable|string',
'cartella_id' => 'nullable|integer|exists:documenti_cartelle,id',
'repository_id' => 'nullable|integer|exists:storage_repositories,id',
]);
$file = $request->file('file');
$path = $file->store('documenti', 'local');
$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;
if ($data['visibilita'] === 'individuo' && $data['visibilita_target_id']) {
@@ -146,15 +235,18 @@ class DocumentoController extends Controller
Documento::create([
'nome_file' => $data['nome_file'],
'file_path' => $path,
'file_path' => $filePath,
'storage_disk' => $disk ?? null,
'tipo' => 'upload',
'tipologia' => $data['tipologia'],
'visibilita' => $data['visibilita'],
'visibilita_target_id' => $data['visibilita_target_id'] ?? null,
'visibilita_target_type' => $visibilitaTargetType,
'mime_type' => $file->getMimeType(),
'dimensione' => $file->getSize(),
'mime_type' => $request->file('file')->getMimeType(),
'dimensione' => $request->file('file')->getSize(),
'user_id' => auth()->id(),
'cartella_id' => $data['cartella_id'] ?? null,
'repository_id' => $repositoryId,
]);
$redirect = $request->_redirect ?? url()->previous();
@@ -166,14 +258,40 @@ class DocumentoController extends Controller
$this->authorizeRead('documenti');
$documento = Documento::findOrFail($documento);
if (!Storage::disk('local')->exists($documento->file_path)) {
abort(404);
}
$extension = pathinfo($documento->file_path, PATHINFO_EXTENSION);
$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,
$filename,
['Content-Type' => $documento->mime_type]
@@ -185,26 +303,49 @@ class DocumentoController extends Controller
$this->authorizeRead('documenti');
$documento = Documento::findOrFail($documento);
if (!Storage::disk('local')->exists($documento->file_path)) {
abort(404);
}
$fullPath = Storage::disk('local')->path($documento->file_path);
$mimeType = $documento->mime_type;
$previewableMimeTypes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
'application/pdf',
'text/plain', 'text/html', 'text/csv',
];
if (in_array($mimeType, $previewableMimeTypes)) {
return response()->file($fullPath, ['Content-Type' => $mimeType]);
if (!in_array($documento->mime_type, $previewableMimeTypes)) {
return response()->view('documenti.preview-fallback', [
'documento' => $documento,
])->header('X-Preview-Fallback', 'true');
}
return response()->view('documenti.preview-fallback', [
'documento' => $documento,
])->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)
@@ -212,13 +353,24 @@ class DocumentoController extends Controller
$this->authorizeDelete('documenti');
$documento = Documento::findOrFail($documento);
if (Storage::disk('local')->exists($documento->file_path)) {
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();
$redirect = request('_redirect', url('/'));
$redirect = request('_redirect', url()->previous());
return redirect($redirect)->with('success', 'Documento eliminato.');
}
@@ -364,21 +516,96 @@ class DocumentoController extends Controller
]);
}
public function massDestroy(Request $request)
public function massDownload(Request $request)
{
$this->authorizeDelete('documenti');
$this->authorizeRead('documenti');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return back()->with('error', 'Nessun documento selezionato.');
}
$documenti = Documento::whereIn('id', $ids)->get();
if ($documenti->isEmpty()) {
return back()->with('error', 'Nessun documento trovato.');
}
$zip = new \ZipArchive();
$zipPath = tempnam(sys_get_temp_dir(), 'docs_') . '.zip';
if ($zip->open($zipPath, \ZipArchive::CREATE) !== true) {
return back()->with('error', 'Impossibile creare l\'archivio ZIP.');
}
$addedCount = 0;
foreach ($documenti as $documento) {
if (Storage::disk('local')->exists($documento->file_path)) {
Storage::disk('local')->delete($documento->file_path);
$extension = pathinfo($documento->file_path, PATHINFO_EXTENSION);
$filename = $extension ? $documento->nome_file . '.' . $extension : $documento->nome_file;
try {
if ($documento->repository_id) {
$repo = StorageRepository::find($documento->repository_id);
if ($repo) {
$filesystem = $this->repoService->buildFilesystem($repo);
if ($filesystem && $filesystem->fileExists($documento->file_path)) {
$stream = $filesystem->readStream($documento->file_path);
if ($stream) {
$zip->addFromString($filename, stream_get_contents($stream));
fclose($stream);
$addedCount++;
}
}
}
} else {
$disk = $this->resolveStorageDisk($documento);
if (Storage::disk($disk)->exists($documento->file_path)) {
$zip->addFile(Storage::disk($disk)->path($documento->file_path), $filename);
$addedCount++;
}
}
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::warning('ZIP download skipped file: ' . $documento->id . ' - ' . $e->getMessage());
}
}
$zip->close();
if ($addedCount === 0) {
unlink($zipPath);
return back()->with('error', 'Nessun file disponibile per il download.');
}
return response()->download($zipPath, 'documenti.zip')->deleteFileAfterSend(true);
}
public function massDestroy(Request $request)
{
$this->authorizeDelete('documenti');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return back()->with('error', 'Nessun documento selezionato.');
}
$documenti = Documento::whereIn('id', $ids)->get();
foreach ($documenti as $documento) {
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();
}
@@ -391,7 +618,7 @@ class DocumentoController extends Controller
$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.');
}
@@ -399,6 +626,8 @@ class DocumentoController extends Controller
$data = $request->validate([
'tipologia' => 'nullable|string',
'visibilita' => 'nullable|string',
'visibilita_target_id' => 'nullable|integer',
'visibilita_target_type' => 'nullable|string',
]);
$tipologia = $data['tipologia'] ?? null;
@@ -425,6 +654,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);
return back()->with('success', count($ids) . ' documenti aggiornati.');
@@ -435,7 +680,7 @@ class DocumentoController extends Controller
$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.');
}
@@ -460,4 +705,40 @@ class DocumentoController extends Controller
return back()->with('success', count($ids) . ' documenti associati.');
}
}
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]);
}
}
+109 -22
View File
@@ -6,6 +6,7 @@ use App\Models\EmailFolder;
use App\Models\EmailMessage;
use App\Models\EmailAttachment;
use App\Models\EmailSetting;
use App\Models\SenderAccount;
use App\Models\MailingList;
use App\Models\Gruppo;
use App\Models\Individuo;
@@ -58,7 +59,8 @@ class EmailController extends Controller
$query->orderBy($sortField === 'sent_at' ? 'received_at' : $sortField, $sortDir);
$messages = $query->paginate(20)->withQueryString();
$perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$messages = $query->paginate($perPage)->withQueryString();
$folders = EmailFolder::orderBy('sort_order')->get();
$mailingLists = MailingList::where('attiva', true)->get();
$gruppi = Gruppo::orderBy('nome')->get();
@@ -90,6 +92,7 @@ class EmailController extends Controller
$mailingLists = MailingList::where('attiva', true)->get();
$gruppi = Gruppo::orderBy('nome')->get();
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$senderAccounts = SenderAccount::active()->get();
$prefill = [];
if ($request->reply_to) {
@@ -108,7 +111,7 @@ class EmailController extends Controller
}
}
return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'prefill'));
return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'prefill', 'senderAccounts'));
}
public function send(Request $request)
@@ -125,11 +128,6 @@ class EmailController extends Controller
$validated = $request->validate($rules);
$settings = EmailSetting::getActive();
if (!$settings) {
return back()->with('error', 'Account email non configurato.');
}
$recipients = $this->resolveRecipients($request);
\Illuminate\Support\Facades\Log::info('Email send attempt', [
@@ -137,44 +135,94 @@ class EmailController extends Controller
'recipients_count' => count($recipients),
'subject' => $validated['subject'],
'destinatario_tipo' => $request->destinatario_tipo,
'to' => $validated['to'] ?? null,
'individui' => $request->individui,
'gruppo_id' => $request->gruppo_id,
'mailing_list_id' => $request->mailing_list_id,
'mittente_id' => $request->mittente_id,
]);
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();
}
$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;
$imapError = null;
$attachments = $this->processAttachments($request);
foreach ($recipients as $recipient) {
try {
$this->sendViaImap($settings, $recipient, $validated['subject'], $validated['body'], $request->cc);
$bodyWithSig = $this->appendSignature($validated['body'], $settings->getSignature(), $settings->signature_enabled ?? false);
$this->sendViaImap($settings, $recipient, $validated['subject'], $bodyWithSig, $request->cc, $attachments);
$imapSuccess = true;
} catch (\Exception $e) {
$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 {
$this->storeSentMessage($settings, $recipients, $validated['subject'], $validated['body'], $request->cc);
\Illuminate\Support\Facades\Log::info('Stored sent message');
$bodyWithSig = $this->appendSignature($validated['body'], $settings->getSignature(), $settings->signature_enabled ?? false);
$this->storeSentMessage($settings, $recipients, $validated['subject'], $bodyWithSig, $request->cc);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Store sent message error', ['error' => $e->getMessage()]);
return back()->with('error', 'Errore nel salvataggio: ' . $e->getMessage())->withInput();
}
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.');
}
private function sendWithSender(Request $request, array $validated, array $recipients)
{
$sender = SenderAccount::findOrFail($request->mittente_id);
$attachmentPaths = $this->resolveAttachmentPaths($request);
$ok = 0;
$errors = [];
foreach ($recipients as $recipient) {
try {
$sender->sendEmail($recipient, $validated['subject'], $validated['body'], $attachmentPaths);
$ok++;
} catch (\Exception $e) {
$errors[] = $recipient . ': ' . $e->getMessage();
}
}
if ($sender->verify_email) {
$reportSubject = '✅ Invio: ' . $validated['subject'] . ' - ' . $sender->email_address;
$reportBody = "Report invio mittente: {$sender->email_address}\n"
. "Destinatari totali: " . count($recipients) . "\n"
. "Inviate con successo: {$ok}\n"
. "Fallite: " . count($errors) . "\n";
if (!empty($errors)) {
$reportBody .= "\nDettaglio errori:\n" . implode("\n", $errors);
}
$sender->sendReport($reportSubject, $reportBody);
}
if ($ok === 0 && !empty($errors)) {
return redirect()->route('email.index', 'sent')->with('error', 'Invio fallito: ' . implode('; ', $errors));
}
$message = $ok . ' email inviate con successo tramite "' . $sender->email_address . '".';
if (!empty($errors)) {
$message .= ' ' . count($errors) . ' fallite.';
}
return redirect()->route('email.index', 'sent')->with('success', $message);
}
public function storeDraft(Request $request)
{
$this->authorizeWrite('email');
@@ -454,10 +502,8 @@ class EmailController extends Controller
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]);
$smtpPassword = $settings->getDecryptedSmtpPassword() ?? $settings->getDecryptedPassword();
@@ -475,8 +521,6 @@ class EmailController extends Controller
$encryption
);
\Illuminate\Support\Facades\Log::info('SMTP DSN', ['dsn' => $dsn]);
try {
$transport = \Symfony\Component\Mailer\Transport::fromDsn($dsn);
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
@@ -498,11 +542,15 @@ class EmailController extends Controller
->from($fromAddress)
->to($to)
->subject($subject)
->text($bodyWithSignature);
->text($body);
if ($cc) {
$email->cc($cc);
}
foreach ($attachmentPaths as $attachmentPath) {
$email->attachFromPath($attachmentPath);
}
$mailer->send($email);
@@ -514,6 +562,45 @@ class EmailController extends Controller
}
}
private function processAttachments(Request $request): array
{
$paths = [];
if ($request->hasFile('allegati')) {
foreach ($request->file('allegati') as $file) {
if ($file->isValid()) {
$paths[] = $file->getRealPath();
}
}
}
return $paths;
}
private function resolveAttachmentPaths(Request $request): array
{
$paths = [];
if ($request->hasFile('allegati')) {
foreach ($request->file('allegati') as $file) {
if ($file->isValid()) {
$paths[] = $file->getRealPath();
}
}
}
$documentIds = $request->input('documenti_selezionati');
if (!empty($documentIds)) {
$ids = is_array($documentIds) ? $documentIds : explode(',', $documentIds);
$docs = Documento::whereIn('id', $ids)->get();
foreach ($docs as $doc) {
if ($doc->file_path && \Illuminate\Support\Facades\Storage::exists($doc->file_path)) {
$paths[] = \Illuminate\Support\Facades\Storage::path($doc->file_path);
}
}
}
return $paths;
}
private function storeSentMessage($settings, $recipients, $subject, $body, $cc = null)
{
$sentFolder = EmailFolder::where('type', 'sent')->first();
+151 -1
View File
@@ -6,7 +6,10 @@ use App\Models\Evento;
use App\Models\Gruppo;
use App\Models\Individuo;
use App\Models\TipologiaEvento;
use App\Services\IcsExportService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\StreamedResponse;
class EventoController extends Controller
{
@@ -41,7 +44,8 @@ class EventoController extends Controller
$query->orderByDesc('created_at');
}
$eventi = $query->paginate(20);
$perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$eventi = $query->paginate($perPage);
$gruppi = Gruppo::orderBy('nome')->get();
return view('eventi.index', compact('eventi', 'gruppi'));
@@ -219,6 +223,55 @@ class EventoController extends Controller
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()
{
$this->authorizeRead('eventi');
@@ -426,4 +479,101 @@ class EventoController extends Controller
return $colors['primary'];
}
public function importIcs()
{
$this->authorizeWrite('eventi');
return view('eventi.import');
}
public function importIcsStore(Request $request)
{
$this->authorizeWrite('eventi');
$request->validate([
'file' => 'required|file|mimes:ics,text/calendar',
]);
$file = $request->file('file');
$content = file_get_contents($file->path());
try {
$vcalendar = \Sabre\VObject\Reader::read($content);
} catch (\Exception $e) {
Log::warning('Errore lettura file ICS: ' . $e->getMessage());
return back()->with('error', 'Il file ICS non è valido: ' . $e->getMessage());
}
$imported = 0;
$skipped = 0;
$errors = [];
foreach ($vcalendar->VEVENT as $vevent) {
try {
$uid = (string) ($vevent->UID ?? '');
if (!empty($uid)) {
$existing = Evento::where('uid_esterno', $uid)->first();
if ($existing) {
$skipped++;
continue;
}
}
$nomeEvento = (string) ($vevent->SUMMARY ?? '');
if (empty($nomeEvento)) {
$skipped++;
continue;
}
$dtStart = $vevent->DTSTART ?? null;
$dtEnd = $vevent->DTEND ?? null;
$dataSpecifica = null;
$oraInizio = null;
$durataMinuti = null;
if ($dtStart) {
$dtStartDateTime = new \DateTime((string) $dtStart);
$dataSpecifica = $dtStartDateTime->format('Y-m-d');
$oraInizio = $dtStartDateTime->format('H:i');
if ($dtEnd) {
$dtEndDateTime = new \DateTime((string) $dtEnd);
$durataMinuti = (int) ($dtEndDateTime->getTimestamp() - $dtStartDateTime->getTimestamp()) / 60;
if ($durataMinuti < 1) {
$durataMinuti = null;
}
}
}
$descrizione = (string) ($vevent->DESCRIPTION ?? '');
$luogo = (string) ($vevent->LOCATION ?? '');
Evento::create([
'nome_evento' => $nomeEvento,
'descrizione' => !empty($descrizione) ? $descrizione : null,
'tipo_recorrenza' => 'singolo',
'data_specifica' => $dataSpecifica,
'ora_inizio' => $oraInizio,
'durata_minuti' => $durataMinuti,
'luogo_indirizzo' => !empty($luogo) ? $luogo : null,
'uid_esterno' => !empty($uid) ? $uid : null,
]);
$imported++;
} catch (\Exception $e) {
Log::warning('Errore import evento ICS: ' . $e->getMessage());
$errors[] = 'Errore: ' . $e->getMessage();
}
}
$message = 'Importati ' . $imported . ' eventi.';
if ($skipped > 0) {
$message .= ' Saltati ' . $skipped . ' eventi (già presenti o senza nome).';
}
if (count($errors) > 0) {
return back()->with('error', $message . ' Errori: ' . implode('; ', $errors));
}
return redirect('/eventi')->with('success', $message);
}
}
+111 -1
View File
@@ -8,6 +8,9 @@ use App\Models\Gruppo;
use App\Models\Individuo;
use App\Models\Diocesi;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Log;
class GruppoController extends Controller
{
@@ -16,6 +19,7 @@ class GruppoController extends Controller
$this->authorizeRead('gruppi');
$viewMode = request('view', 'table');
$perPage = in_array((int) request('perPage'), [10, 20, 25, 50, 100]) ? (int) request('perPage') : 20;
$user = auth()->user();
$vista = \App\Models\VistaReport::where('user_id', $user->id)
@@ -47,7 +51,16 @@ class GruppoController extends Controller
});
if ($viewMode === 'table') {
$gruppi = $this->sortGruppiHierarchically($allGruppi);
$sorted = $this->sortGruppiHierarchically($allGruppi);
$page = Paginator::resolveCurrentPage();
$gruppi = new LengthAwarePaginator(
$sorted->forPage($page, $perPage)->values(),
$sorted->count(),
$perPage,
$page,
['path' => Paginator::resolveCurrentPath()]
);
$gruppi->appends(request()->except('page'));
} else {
$gruppi = $allGruppi;
}
@@ -300,4 +313,101 @@ class GruppoController extends Controller
return redirect()->route('gruppi.index')->with('success', 'Vista eliminata.');
}
public function import()
{
$this->authorizeWrite('gruppi');
return view('gruppi.import');
}
public function importStore(Request $request)
{
$this->authorizeWrite('gruppi');
$request->validate([
'file' => 'required|file|mimes:csv,txt',
]);
$file = $request->file('file');
$handle = fopen($file->path(), 'r');
$header = fgetcsv($handle, 1000, ',');
if ($header === false) {
fclose($handle);
return back()->with('error', 'Il file CSV non è valido.');
}
$header = array_map('trim', $header);
$imported = 0;
$skipped = 0;
$errors = [];
$rowNumber = 1;
while (($row = fgetcsv($handle, 1000, ',')) !== false) {
$rowNumber++;
if (count($row) < count($header)) {
$skipped++;
continue;
}
$data = array_combine($header, $row);
if ($data === false) {
$skipped++;
continue;
}
$data = array_map('trim', $data);
$nome = $data['nome'] ?? '';
if (empty($nome)) {
$skipped++;
continue;
}
try {
Gruppo::create([
'nome' => $nome,
'descrizione' => $data['descrizione'] ?? null,
'parent_id' => !empty($data['parent_id']) ? (int) $data['parent_id'] : null,
'diocesi_id' => !empty($data['diocesi_id']) ? (int) $data['diocesi_id'] : null,
'indirizzo_incontro' => $data['indirizzo_incontro'] ?? null,
'cap_incontro' => $data['cap_incontro'] ?? null,
'città_incontro' => $data['città_incontro'] ?? null,
'sigla_provincia_incontro' => $data['sigla_provincia_incontro'] ?? null,
]);
$imported++;
} catch (\Exception $e) {
Log::warning('Errore import gruppo riga ' . $rowNumber . ': ' . $e->getMessage());
$errors[] = 'Errore riga ' . $rowNumber . ': ' . $e->getMessage();
}
}
fclose($handle);
$message = 'Importati ' . $imported . ' gruppi.';
if ($skipped > 0) {
$message .= ' Saltate ' . $skipped . ' righe (vuote o incomplete).';
}
if (count($errors) > 0) {
return back()->with('error', $message . ' Errori: ' . implode('; ', $errors));
}
return redirect()->route('gruppi.index')->with('success', $message);
}
public function downloadTemplate()
{
$this->authorizeRead('gruppi');
$headers = ['nome', 'descrizione', 'parent_id', 'diocesi_id', 'indirizzo_incontro', 'cap_incontro', 'città_incontro', 'sigla_provincia_incontro'];
$example = ['Gruppo Giovani', 'Giovani dai 18 ai 30 anni', '', '', 'Via Roma 10', '00100', 'Roma', 'RM'];
$csv = implode(',', $headers) . "\n" . implode(',', $example);
return response($csv, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="template_gruppi.csv"',
]);
}
}
+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',
]);
}
}
+19 -2
View File
@@ -1,10 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Individuo;
use App\Models\Documento;
use App\Models\Evento;
use App\Models\Gruppo;
use App\Models\Individuo;
use App\Models\EmailMessage;
use App\Models\MailingList;
use App\Models\Notifica;
use App\Services\BackupService;
use Illuminate\Support\Facades\Auth;
class HomeController extends Controller
@@ -12,11 +19,21 @@ class HomeController extends Controller
public function index()
{
$user = Auth::user();
$backupsCount = 0;
if ($user->canManage('settings')) {
$backupsCount = count(app(BackupService::class)->list());
}
$stats = [
'individui' => Individuo::count(),
'gruppi' => Gruppo::count(),
'backups' => $backupsCount,
'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();
+152 -25
View File
@@ -3,29 +3,42 @@
namespace App\Http\Controllers;
use App\Models\AppSetting;
use App\Models\CalendarioConnessione;
use App\Models\Documento;
use App\Models\EmailSetting;
use App\Models\Ruolo;
use App\Models\SenderAccount;
use App\Models\StorageRepository;
use App\Models\TipologiaDocumento;
use App\Models\TipologiaEvento;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class ImpostazioniController extends Controller
{
public function index()
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$tipologie = TipologiaDocumento::orderBy('ordine')->get();
$tipologieEventi = TipologiaEvento::orderBy('ordine')->get();
$ruoli = Ruolo::orderBy('ordine')->get();
$appSettings = AppSetting::first();
$emailSettings = EmailSetting::first() ?? new EmailSetting();
$senderAccounts = SenderAccount::orderBy('email_address')->get();
$repositories = StorageRepository::orderBy('ordine')->get();
$googleDriveNewToken = session('google_drive_new_token');
$calendarioConnessioni = CalendarioConnessione::orderBy('ordine')->get();
return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'appSettings'));
return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni'));
}
public function saveAppSettings(Request $request)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$validated = $request->validate([
'nome_applicazione' => 'nullable|string|max:100',
@@ -34,25 +47,123 @@ class ImpostazioniController extends Controller
'app_version' => 'nullable|string|max:50',
'dashboard_welcome' => 'nullable|string|max:1000',
'show_version' => 'boolean',
'documenti_storage_disk' => 'nullable|string|in:local,public',
'documenti_storage_path' => 'nullable|string|max:255',
]);
$settings = AppSetting::first() ?? new AppSetting();
$oldDisk = $settings->documenti_storage_disk ?? 'local';
$oldPath = $settings->documenti_storage_path ?? 'documenti';
$settings->nome_applicazione = $validated['nome_applicazione'] ?? null;
$settings->nome_organizzazione = $validated['nome_organizzazione'] ?? null;
$settings->footer_text = $validated['footer_text'] ?? null;
$settings->app_version = $validated['app_version'] ?? null;
$settings->dashboard_welcome = $validated['dashboard_welcome'] ?? null;
$settings->nome_applicazione = $validated['nome_applicazione'] ?? $settings->nome_applicazione ?? '';
$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->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();
$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.');
}
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)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$validated = $request->validate([
'nome' => 'required|string|max:50|unique:tipologie_documenti,nome',
@@ -73,7 +184,7 @@ class ImpostazioniController extends Controller
public function tipologieUpdate(Request $request, $id)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$tipologia = TipologiaDocumento::findOrFail($id);
@@ -90,7 +201,7 @@ class ImpostazioniController extends Controller
public function tipologieDestroy($id)
{
$this->authorizeDelete('viste');
$this->authorizeDelete('settings');
$tipologia = TipologiaDocumento::findOrFail($id);
$count = $tipologia->documenti()->count();
@@ -105,7 +216,7 @@ class ImpostazioniController extends Controller
public function tipologieReorder(Request $request)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$order = $request->input('order', []);
@@ -118,7 +229,7 @@ class ImpostazioniController extends Controller
public function ruoliStore(Request $request)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$validated = $request->validate([
'nome' => 'required|string|max:50|unique:ruoli,nome',
@@ -139,7 +250,7 @@ class ImpostazioniController extends Controller
public function ruoliUpdate(Request $request, $id)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$ruolo = Ruolo::findOrFail($id);
@@ -156,14 +267,21 @@ class ImpostazioniController extends Controller
public function ruoliDestroy($id)
{
$this->authorizeDelete('viste');
$this->authorizeDelete('settings');
$ruolo = Ruolo::findOrFail($id);
$count = \DB::table('gruppo_individuo')
$allRows = \DB::table('gruppo_individuo')
->whereNotNull('ruolo_ids')
->whereRaw("JSON_CONTAINS(ruolo_ids, ?)", [json_encode($id)])
->count();
->get(['id', 'ruolo_ids']);
$count = 0;
foreach ($allRows as $row) {
$ids = json_decode($row->ruolo_ids, true);
if (is_array($ids) && in_array((int) $id, $ids)) {
$count++;
}
}
if ($count > 0) {
return redirect('/impostazioni#ruoli')->with('error', 'Impossibile eliminare: ' . $count . ' membri associati a questo ruolo.');
@@ -176,7 +294,7 @@ class ImpostazioniController extends Controller
public function ruoliReorder(Request $request)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$order = $request->input('order', []);
@@ -189,7 +307,7 @@ class ImpostazioniController extends Controller
public function tipologieEventiStore(Request $request)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$validated = $request->validate([
'nome' => 'required|string|max:50|unique:tipologie_eventi,nome',
@@ -210,7 +328,7 @@ class ImpostazioniController extends Controller
public function tipologieEventiUpdate(Request $request, $id)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$tipologia = TipologiaEvento::findOrFail($id);
@@ -227,7 +345,7 @@ class ImpostazioniController extends Controller
public function tipologieEventiDestroy($id)
{
$this->authorizeDelete('viste');
$this->authorizeDelete('settings');
$tipologia = TipologiaEvento::findOrFail($id);
$count = $tipologia->eventi()->count();
@@ -242,7 +360,7 @@ class ImpostazioniController extends Controller
public function tipologieEventiReorder(Request $request)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$order = $request->input('order', []);
@@ -255,7 +373,7 @@ class ImpostazioniController extends Controller
public function uploadLogo(Request $request)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$request->validate([
'logo' => 'nullable|image|mimes:png,jpg,jpeg,gif,svg|max:2048',
@@ -280,12 +398,21 @@ class ImpostazioniController extends Controller
$settings->save();
}
$linkPath = public_path('storage');
if (!file_exists($linkPath) && !is_link($linkPath)) {
try {
symlink(storage_path('app/public'), $linkPath);
} catch (\Throwable $e) {
Log::warning('Impossibile creare symlink storage: ' . $e->getMessage());
}
}
return redirect('/impostazioni#logo')->with('success', 'Logo caricato con successo.');
}
public function removeLogo(Request $request)
{
$this->authorizeWrite('viste');
$this->authorizeWrite('settings');
$type = $request->input('type');
+11 -6
View File
@@ -17,7 +17,8 @@ class IndividuoController extends Controller
public function index(Request $request)
{
$this->authorizeRead('individui');
$individui = Individuo::with('contatti')->orderBy('cognome')->orderBy('nome')->paginate(20);
$perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$individui = Individuo::with('contatti')->orderBy('cognome')->orderBy('nome')->paginate($perPage);
$vista = null;
@@ -210,11 +211,15 @@ public function create()
\Log::info('Individuo dati salvati', ['id' => $individuo->id]);
$contattiData = $request->input('contatti');
\Log::info('Contatti ricevuti:', ['contatti' => $contattiData, 'tipo' => gettype($contattiData)]);
$individuo->contatti()->delete();
\Log::info('Contatti precedenti eliminati, count ora:', ['count' => $individuo->contatti()->count()]);
if ($request->has('contatti')) {
$contattiData = $request->input('contatti');
\Log::info('Contatti ricevuti:', ['contatti' => $contattiData, 'tipo' => gettype($contattiData)]);
$individuo->contatti()->delete();
\Log::info('Contatti precedenti eliminati, count ora:', ['count' => $individuo->contatti()->count()]);
} else {
$contattiData = null;
}
if (!empty($contattiData) && is_array($contattiData)) {
$created = 0;
+243 -9
View File
@@ -1,12 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\MailingList;
use App\Models\MailingContact;
use App\Models\MailingMessaggio;
use App\Models\SenderAccount;
use App\Models\Individuo;
use App\Models\Documento;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class MailingController extends Controller
@@ -24,6 +29,7 @@ class MailingController extends Controller
$liste = MailingList::all();
$documenti = Documento::orderBy('nome_file')->get();
$senderAccounts = SenderAccount::active()->get();
$documentiSelezionati = collect();
if ($request->has('documenti_selezionati')) {
@@ -31,7 +37,7 @@ class MailingController extends Controller
$documentiSelezionati = Documento::whereIn('id', $docIds)->get();
}
return view('mailing.nuovo', compact('individui', 'liste', 'documenti', 'documentiSelezionati'));
return view('mailing.nuovo', compact('individui', 'liste', 'documenti', 'documentiSelezionati', 'senderAccounts'));
}
public function invia(Request $request)
@@ -44,8 +50,14 @@ class MailingController extends Controller
'destinatari_ids' => 'nullable|string',
'documenti_selezionati' => 'nullable|string',
'allegato' => 'nullable|file|max:10240',
'mittente_id' => 'nullable|integer|exists:sender_accounts,id',
]);
$sender = null;
if (!empty($data['mittente_id'])) {
$sender = SenderAccount::findOrFail($data['mittente_id']);
}
$documentiAllegati = [];
if ($request->hasFile('allegato')) {
@@ -72,13 +84,90 @@ class MailingController extends Controller
$documentiAllegati = array_merge($documentiAllegati, $docIds);
}
$destinatari = [];
$destinatari = collect();
if (!empty($data['destinatari_ids'])) {
$ids = array_filter(array_map('trim', explode(',', $data['destinatari_ids'])));
$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).');
$emails = $destinatari->flatMap(fn($ind) => $ind->contatti->where('tipo', 'email')->pluck('valore'))
->filter()->unique()->values();
if ($emails->isEmpty()) {
return back()->with('error', 'Nessun destinatario email valido trovato.')->withInput();
}
$attachmentPaths = $this->resolveMailingAttachmentPaths($documentiAllegati);
$messaggio = MailingMessaggio::create([
'mailing_list_id' => $data['lista_id'] ?? null,
'user_id' => auth()->id(),
'oggetto' => $data['oggetto'],
'corpo' => $data['corpo'],
'stato' => 'in_coda',
'totale_destinatari' => $emails->count(),
'mittente_nome' => $sender?->email_name,
'mittente_email' => $sender?->email_address,
]);
$ok = 0;
$fail = 0;
$logFalliti = [];
foreach ($emails as $email) {
try {
if ($sender) {
$sender->sendEmail($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
} else {
$this->sendViaSystem($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
}
$ok++;
} catch (\Throwable $e) {
$fail++;
$logFalliti[] = ['email' => $email, 'errore' => $e->getMessage()];
Log::error('Mailing send failed', ['email' => $email, 'error' => $e->getMessage()]);
}
}
$stato = $fail === 0 ? 'inviato' : ($ok > 0 ? 'parziale' : 'fallito');
$messaggio->update([
'stato' => $stato,
'invii_ok' => $ok,
'invii_falliti' => $fail,
'log_falliti' => $logFalliti,
'inviato_al' => now(),
]);
$senderLabel = $sender ? '"' . $sender->email_address . '"' : 'sistema predefinito';
if ($sender && $sender->verify_email && ($ok > 0 || $stato === 'fallito')) {
$reportSubject = '✅ Report Invio - ' . $data['oggetto'];
$reportBody = "Report invio mailing\n"
. "Mittente: {$sender->email_name} <{$sender->email_address}>\n"
. "Oggetto: {$data['oggetto']}\n"
. "Totale destinatari: {$emails->count()}\n"
. "Inviate con successo: {$ok}\n"
. "Fallite: {$fail}\n";
if (!empty($logFalliti)) {
$reportBody .= "\nDettaglio errori:\n";
foreach ($logFalliti as $f) {
$reportBody .= "- {$f['email']}: {$f['errore']}\n";
}
}
$sender->sendReport($reportSubject, $reportBody);
}
$msg = "Invio completato tramite {$senderLabel}: {$ok} inviate, {$fail} fallite.";
if ($ok > 0) {
return redirect('/mailing-liste')->with('success', $msg);
}
return back()->with('error', $msg);
}
public function invio(Request $request)
@@ -86,8 +175,9 @@ class MailingController extends Controller
$this->authorizeWrite('mailing');
$liste = MailingList::where('attiva', true)->orderBy('nome')->get();
$documenti = Documento::orderBy('nome_file')->get();
$senderAccounts = SenderAccount::active()->get();
return view('mailing.invio', compact('liste', 'documenti'));
return view('mailing.invio', compact('liste', 'documenti', 'senderAccounts'));
}
public function invioElabora(Request $request)
@@ -101,8 +191,14 @@ class MailingController extends Controller
'documenti_selezionati' => 'nullable|array',
'documenti_selezionati.*' => 'exists:documenti,id',
'allegato' => 'nullable|file|max:10240',
'mittente_id' => 'nullable|integer|exists:sender_accounts,id',
]);
$sender = null;
if (!empty($data['mittente_id'])) {
$sender = SenderAccount::findOrFail($data['mittente_id']);
}
$documentiAllegati = [];
if ($request->hasFile('allegato')) {
@@ -133,10 +229,148 @@ class MailingController extends Controller
->with('individuo.contatti')
->get();
$destinatari = $contatti->map(function($c) {
return $c->individuo->contatti->where('tipo', 'email')->first()?->valore;
})->filter()->unique()->values();
$emails = $contatti->map(fn($c) => $c->individuo?->contatti->where('tipo', 'email')->first()?->valore)
->filter()->unique()->values();
return back()->with('success', 'Messaggio pronto per l\'invio a ' . $destinatari->count() . ' destinatari unici da ' . count($data['liste']) . ' lista(e).');
if ($emails->isEmpty()) {
return back()->with('error', 'Nessun destinatario email valido trovato nelle liste selezionate.');
}
$attachmentPaths = $this->resolveMailingAttachmentPaths($documentiAllegati);
$listeNomi = MailingList::whereIn('id', $data['liste'])->pluck('nome')->implode(', ');
$messaggio = MailingMessaggio::create([
'mailing_list_id' => null,
'user_id' => auth()->id(),
'oggetto' => $data['oggetto'],
'corpo' => $data['corpo'],
'stato' => 'in_coda',
'totale_destinatari' => $emails->count(),
'mittente_nome' => $sender?->email_name,
'mittente_email' => $sender?->email_address,
]);
$ok = 0;
$fail = 0;
$logFalliti = [];
foreach ($emails as $email) {
try {
if ($sender) {
$sender->sendEmail($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
} else {
$this->sendViaSystem($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
}
$ok++;
} catch (\Throwable $e) {
$fail++;
$logFalliti[] = ['email' => $email, 'errore' => $e->getMessage()];
Log::error('Mailing bulk send failed', ['email' => $email, 'error' => $e->getMessage()]);
}
}
$stato = $fail === 0 ? 'inviato' : ($ok > 0 ? 'parziale' : 'fallito');
$messaggio->update([
'stato' => $stato,
'invii_ok' => $ok,
'invii_falliti' => $fail,
'log_falliti' => $logFalliti,
'inviato_al' => now(),
]);
$senderLabel = $sender ? '"' . $sender->email_address . '"' : 'sistema predefinito';
if ($sender && $sender->verify_email && ($ok > 0 || $stato === 'fallito')) {
$reportSubject = '✅ Report Invio Massivo - ' . $data['oggetto'];
$reportBody = "Report invio massivo\n"
. "Mittente: {$sender->email_name} <{$sender->email_address}>\n"
. "Liste: {$listeNomi}\n"
. "Oggetto: {$data['oggetto']}\n"
. "Totale destinatari: {$emails->count()}\n"
. "Inviate con successo: {$ok}\n"
. "Fallite: {$fail}\n";
if (!empty($logFalliti)) {
$reportBody .= "\nDettaglio errori:\n";
foreach ($logFalliti as $f) {
$reportBody .= "- {$f['email']}: {$f['errore']}\n";
}
}
$sender->sendReport($reportSubject, $reportBody);
}
$msg = "Invio massivo completato tramite {$senderLabel}: {$ok} inviate, {$fail} fallite.";
if ($ok > 0) {
return redirect('/')->with('success', $msg);
}
return back()->with('error', $msg);
}
}
private function sendViaSystem(string $to, string $subject, string $body, array $attachmentPaths = []): void
{
$settings = \App\Models\EmailSetting::getActive();
if (!$settings) {
throw new \Exception('Account email principale non configurato.');
}
$smtpPassword = $settings->getDecryptedSmtpPassword() ?? $settings->getDecryptedPassword();
$encryption = $settings->smtp_encryption ?? 'tls';
$scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp';
$dsn = sprintf(
'%s://%s:%s@%s:%d?encryption=%s',
$scheme,
rawurlencode($settings->smtp_username ?? $settings->email_address),
rawurlencode($smtpPassword),
$settings->smtp_host ?? 'smtp.gmail.com',
$settings->smtp_port ?? 587,
$encryption
);
$transport = \Symfony\Component\Mailer\Transport::fromDsn($dsn);
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
$fromName = $settings->email_name ?? '';
if (filter_var($fromName, FILTER_VALIDATE_EMAIL)) {
$fromName = 'Glastree';
} else {
$fromName = preg_replace('/[^\p{L}\p{N}\s]/u', '', $fromName);
$fromName = trim($fromName);
if (empty($fromName) || strlen($fromName) > 50) {
$fromName = 'Glastree';
}
}
$fromAddress = \Symfony\Component\Mime\Address::create($settings->email_address, $fromName);
$email = (new \Symfony\Component\Mime\Email())
->from($fromAddress)
->to($to)
->subject($subject)
->text($body);
foreach ($attachmentPaths as $path) {
$email->attachFromPath($path);
}
$mailer->send($email);
}
private function resolveMailingAttachmentPaths(array $documentIds): array
{
$paths = [];
if (empty($documentIds)) {
return $paths;
}
$docs = Documento::whereIn('id', $documentIds)->get();
foreach ($docs as $doc) {
if ($doc->file_path && Storage::exists($doc->file_path)) {
$paths[] = Storage::path($doc->file_path);
}
}
return $paths;
}
}
+425 -87
View File
@@ -1,15 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Individuo;
use App\Helpers\ReportColumnRegistry;
use App\Models\Gruppo;
use App\Models\Individuo;
use App\Models\Evento;
use App\Models\Documento;
use App\Models\MailingList;
use App\Models\Contatto;
use App\Models\TipologiaDocumento;
use App\Models\ReportCustom;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ReportController extends Controller
{
@@ -30,11 +36,24 @@ class ReportController extends Controller
$prebuiltReports = $this->getPrebuiltReports();
$customReports = \App\Models\ReportCustom::where('user_id', auth()->id())
$customReports = ReportCustom::where('user_id', auth()->id())
->orderBy('created_at', 'desc')
->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);
}
return view('report.index', compact(
'stats',
'prebuiltReports',
'customReports',
'tipologieDocumento',
'columnOptions'
));
}
public function run(Request $request)
@@ -44,28 +63,24 @@ class ReportController extends Controller
}
$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);
return view('report.result', compact('data', 'reportType', 'report'));
}
$data = $this->resolveReport($request);
if (!$data) {
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)
@@ -74,8 +89,8 @@ class ReportController extends Controller
abort(403);
}
$report = \App\Models\ReportCustom::findOrFail($id);
$report = ReportCustom::findOrFail($id);
if ($report->user_id !== auth()->id()) {
abort(403);
}
@@ -95,15 +110,38 @@ class ReportController extends Controller
'nome' => 'required|string|max:255',
'descrizione' => 'nullable|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');
}
ReportCustom::create([
'user_id' => auth()->id(),
'nome' => $data['nome'],
'descrizione' => $data['descrizione'] ?? null,
'tipo_report' => $data['tipo_report'],
'config' => $data['config'] ?? [],
'config' => $config,
]);
return redirect()->route('report.index')->with('success', 'Report personalizzato creato.');
@@ -115,7 +153,7 @@ class ReportController extends Controller
abort(403);
}
$report = \App\Models\ReportCustom::where('user_id', auth()->id())->findOrFail($id);
$report = ReportCustom::where('user_id', auth()->id())->findOrFail($id);
$report->delete();
return redirect()->route('report.index')->with('success', 'Report personalizzato eliminato.');
@@ -127,39 +165,24 @@ class ReportController extends Controller
abort(403);
}
$reportType = $request->input('report');
$customReportId = $request->input('custom_id');
if ($customReportId) {
$report = \App\Models\ReportCustom::findOrFail($customReportId);
$report = ReportCustom::findOrFail($customReportId);
if ($report->user_id !== auth()->id()) {
abort(403);
}
$data = $this->runCustomReport($report);
return view('report.print-preview', compact('reportType', 'data', 'report'));
return view('report.print-preview', compact('data', 'report'));
}
$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,
};
$data = $this->resolveReport($request);
if (!$data) {
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)
@@ -168,9 +191,49 @@ class ReportController extends Controller
abort(403);
}
$reportType = $request->input('report');
$customReportId = $request->input('custom_id');
$data = match ($reportType) {
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) {
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) {
$handle = fopen('php://output', 'w');
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
if (!empty($data['headers']) && !empty($data['rows'])) {
fputcsv($handle, $data['headers']);
foreach ($data['rows'] as $row) {
fputcsv($handle, $row);
}
}
fclose($handle);
});
$response->headers->set('Content-Type', 'text/csv; charset=utf-8');
$response->headers->set('Content-Disposition', 'attachment; filename="' . $filename . '"');
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(),
@@ -183,30 +246,14 @@ class ReportController extends Controller
'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),
default => null,
};
if (!$data) {
return redirect()->route('report.index')->with('error', 'Report non trovato.');
}
$response = new \Symfony\Component\HttpFoundation\StreamedResponse(function () use ($data) {
$handle = fopen('php://output', 'w');
if (!empty($data['headers']) && !empty($data['rows'])) {
fputcsv($handle, $data['headers']);
foreach ($data['rows'] as $row) {
fputcsv($handle, $row);
}
}
fclose($handle);
});
$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"');
return $response;
}
protected function getPrebuiltReports(): array
@@ -218,6 +265,7 @@ class ReportController extends Controller
'descrizione' => 'Distribuzione degli individui per genere (Maschio/Femmina)',
'icona' => 'fa-venus-mars',
'colore' => 'info',
'has_tipologia_filter' => false,
],
[
'id' => 'individui_per_eta',
@@ -225,6 +273,7 @@ class ReportController extends Controller
'descrizione' => 'Distribuzione degli individui per fasce d\'età',
'icona' => 'fa-birthday-cake',
'colore' => 'success',
'has_tipologia_filter' => false,
],
[
'id' => 'gruppi_gerarchia',
@@ -232,6 +281,7 @@ class ReportController extends Controller
'descrizione' => 'Struttura ad albero completa di tutti i gruppi con livelli',
'icona' => 'fa-sitemap',
'colore' => 'warning',
'has_tipologia_filter' => false,
],
[
'id' => 'gruppi_membri',
@@ -239,6 +289,7 @@ class ReportController extends Controller
'descrizione' => 'Elenco di tutti i gruppi con il conteggio dei membri',
'icona' => 'fa-users',
'colore' => 'primary',
'has_tipologia_filter' => false,
],
[
'id' => 'eventi_calendario',
@@ -246,6 +297,7 @@ class ReportController extends Controller
'descrizione' => 'Tutti gli eventi programmati per il mese corrente',
'icona' => 'fa-calendar-alt',
'colore' => 'danger',
'has_tipologia_filter' => false,
],
[
'id' => 'documenti_per_tipo',
@@ -253,6 +305,7 @@ class ReportController extends Controller
'descrizione' => 'Distribuzione dei documenti per tipologia',
'icona' => 'fa-file-alt',
'colore' => 'secondary',
'has_tipologia_filter' => false,
],
[
'id' => 'contatti_per_tipo',
@@ -260,6 +313,7 @@ class ReportController extends Controller
'descrizione' => 'Distribuzione dei contatti per tipo (telefono, email, ecc.)',
'icona' => 'fa-address-book',
'colore' => 'teal',
'has_tipologia_filter' => false,
],
[
'id' => 'mailing_list_dettaglio',
@@ -267,6 +321,7 @@ class ReportController extends Controller
'descrizione' => 'Elenco completo delle mailing list con i contatti associati',
'icona' => 'fa-envelope',
'colore' => 'purple',
'has_tipologia_filter' => false,
],
[
'id' => 'individui_senza_contatti',
@@ -274,6 +329,7 @@ class ReportController extends Controller
'descrizione' => 'Lista degli individui che non hanno nessun contatto registrato',
'icona' => 'fa-user-slash',
'colore' => 'dark',
'has_tipologia_filter' => false,
],
[
'id' => 'individui_senza_gruppo',
@@ -281,6 +337,7 @@ class ReportController extends Controller
'descrizione' => 'Lista degli individui non associati a nessun gruppo',
'icona' => 'fa-user-friends',
'colore' => 'orange',
'has_tipologia_filter' => false,
],
[
'id' => 'eventi_per_gruppo',
@@ -288,6 +345,7 @@ class ReportController extends Controller
'descrizione' => 'Distribuzione degli eventi per gruppo di appartenenza',
'icona' => 'fa-calendar-check',
'colore' => 'pink',
'has_tipologia_filter' => false,
],
[
'id' => 'documenti_orfani',
@@ -295,6 +353,55 @@ class ReportController extends Controller
'descrizione' => 'Documenti non collegati a nessun individuo, gruppo o evento',
'icona' => 'fa-file-excel',
'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,
],
];
}
@@ -342,7 +449,7 @@ class ReportController extends Controller
->whereYear('data_nascita', '>=', $now->year - $fascia['max'])
->whereYear('data_nascita', '<=', $now->year - $fascia['min'])
->count();
$rows[] = ['fascia' => $fascia['label'], 'totale' => $count];
$totalWithDate += $count;
}
@@ -361,10 +468,10 @@ class ReportController extends Controller
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 = [];
foreach ($rootGruppi as $gruppo) {
$rows[] = [
'livello' => 0,
@@ -462,7 +569,7 @@ class ReportController extends Controller
return [
'title' => 'Documenti per Tipologia',
'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(),
'detail_rows' => $rows->map(fn($r) => (array) $r)->toArray(),
];
@@ -478,7 +585,7 @@ class ReportController extends Controller
return [
'title' => 'Contatti per Tipo',
'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(),
'detail_rows' => $rows->map(fn($r) => (array) $r)->toArray(),
];
@@ -495,7 +602,7 @@ class ReportController extends Controller
'lista' => $lista->nome,
'stato' => $lista->attiva ? 'Attiva' : 'Disattiva',
'individuo' => $contatto->individuo?->nome_completo ?? 'N/D',
'email' => $contatto->email ?? '-',
'email' => $contatto->valore ?? '-',
];
}
}
@@ -603,8 +710,8 @@ class ReportController extends Controller
$rows = $documenti->map(function ($doc) {
return [
'nome' => $doc->nome_file,
'tipologia' => ucfirst($doc->tipologia),
'visibilita' => ucfirst($doc->visibilita),
'tipologia' => ucfirst($doc->tipologia ?? ''),
'visibilita' => ucfirst($doc->visibilita ?? ''),
'data' => $doc->created_at->format('d/m/Y'),
];
})->toArray();
@@ -618,7 +725,209 @@ class ReportController extends Controller
];
}
protected function runCustomReport(\App\Models\ReportCustom $report): array
protected function reportDocumentiCompleto(?string $tipologiaFilter = null): array
{
$query = Documento::with('user')
->orderBy('created_at', 'desc');
if ($tipologiaFilter) {
$query->where('tipologia', $tipologiaFilter);
}
$documenti = $query->get();
$rows = $documenti->map(function ($doc) {
return [
'nome' => $doc->nome_file,
'tipologia' => ucfirst($doc->tipologia ?? ''),
'visibilita' => ucfirst($doc->visibilita ?? ''),
'dimensione' => $doc->dimensione ? number_format($doc->dimensione / 1024, 1) . ' KB' : '-',
'uploader' => $doc->user?->name ?? '-',
'data' => $doc->created_at->format('d/m/Y'),
];
})->toArray();
$title = $tipologiaFilter
? 'Documenti (tipologia: ' . ucfirst($tipologiaFilter) . ')'
: 'Documenti (dettaglio completo)';
return [
'title' => $title,
'headers' => ['Nome File', 'Tipologia', 'Visibilità', 'Dimensione', 'Caricato Da', 'Data'],
'rows' => array_map(fn($r) => [$r['nome'], $r['tipologia'], $r['visibilita'], $r['dimensione'], $r['uploader'], $r['data']], $rows),
'summary' => 'Totale documenti: ' . $documenti->count(),
'detail_rows' => $rows,
];
}
protected function reportScadenzeDocumenti(?string $tipologiaFilter = null): array
{
$query = Individuo::whereNotNull('scadenza_documento')
->whereNotNull('tipo_documento')
->orderBy('scadenza_documento');
if ($tipologiaFilter) {
$query->where('tipo_documento', $tipologiaFilter);
}
$individui = $query->get();
$rows = $individui->map(function ($ind) {
$scadenza = $ind->scadenza_documento;
$giorniMancanti = $scadenza ? now()->diffInDays($scadenza, false) : null;
return [
'codice' => $ind->codice_id,
'cognome' => $ind->cognome,
'nome' => $ind->nome,
'tipo_documento' => ucfirst($ind->tipo_documento ?? ''),
'numero' => $ind->numero_documento ?? '-',
'scadenza' => $scadenza?->format('d/m/Y') ?? '-',
'stato' => $giorniMancanti !== null
? ($giorniMancanti < 0
? ('Scaduto da ' . abs($giorniMancanti) . ' gg')
: ($giorniMancanti <= 30
? 'Scade tra ' . $giorniMancanti . ' gg'
: 'In regola (' . $giorniMancanti . ' gg)'))
: '-',
];
})->toArray();
$title = $tipologiaFilter
? 'Scadenze Documenti (tipo: ' . ucfirst($tipologiaFilter) . ')'
: 'Scadenze Documenti';
return [
'title' => $title,
'headers' => ['Codice', 'Cognome', 'Nome', 'Tipo Documento', 'Numero', 'Scadenza', 'Stato'],
'rows' => array_map(fn($r) => [$r['codice'], $r['cognome'], $r['nome'], $r['tipo_documento'], $r['numero'], $r['scadenza'], $r['stato']], $rows),
'summary' => 'Individui con documento: ' . $individui->count(),
'detail_rows' => $rows,
];
}
protected function reportIndividuiCompleto(): array
{
$individui = Individuo::with(['contatti', 'gruppi'])
->orderBy('cognome')
->orderBy('nome')
->get();
$rows = $individui->map(function ($ind) {
$email = $ind->contatti->where('tipo', 'email')->first()?->valore
?? $ind->email
?? '-';
$telefono = $ind->contatti->whereIn('tipo', ['telefono', 'cellulare'])->first()?->valore
?? $ind->telefono
?? '-';
$gruppi = $ind->gruppi->pluck('nome')->implode(', ') ?: '-';
return [
'codice' => $ind->codice_id,
'cognome' => $ind->cognome,
'nome' => $ind->nome,
'data_nascita' => $ind->data_nascita?->format('d/m/Y') ?? '-',
'genere' => match ($ind->genere) { 'M' => 'M', 'F' => 'F', default => '-' },
'email' => $email,
'telefono' => $telefono,
'città' => $ind->città ?? '-',
'gruppi' => $gruppi,
];
})->toArray();
return [
'title' => 'Anagrafica Completa Individui',
'headers' => ['Codice', 'Cognome', 'Nome', 'Data Nascita', 'Genere', 'Email', 'Telefono', 'Città', 'Gruppi'],
'rows' => array_map(fn($r) => [$r['codice'], $r['cognome'], $r['nome'], $r['data_nascita'], $r['genere'], $r['email'], $r['telefono'], $r['città'], $r['gruppi']], $rows),
'summary' => 'Totale individui: ' . $individui->count(),
'detail_rows' => $rows,
];
}
protected function reportGruppiCompleto(): array
{
$gruppi = Gruppo::withCount('individui')
->with('diocesi')
->orderBy('nome')
->get();
$rows = $gruppi->map(function ($g) {
$parent = $g->parent;
return [
'nome' => $g->nome,
'descrizione' => $g->descrizione ?? '-',
'diocesi' => $g->diocesi?->nome ?? '-',
'livello' => $g->parent_id ? (count($g->getAncestors()) + 1) : 0,
'membri' => $g->individui_count,
'padre' => $parent?->nome ?? '-',
'città' => $g->città_incontro ?? '-',
];
})->toArray();
return [
'title' => 'Gruppi (dettaglio completo)',
'headers' => ['Nome', 'Descrizione', 'Diocesi', 'Livello', 'Membri', 'Padre', 'Città'],
'rows' => array_map(fn($r) => [$r['nome'], $r['descrizione'], $r['diocesi'], $r['livello'], $r['membri'], $r['padre'], $r['città']], $rows),
'summary' => 'Totale gruppi: ' . $gruppi->count() . ' | Totale membri: ' . $gruppi->sum('individui_count'),
'detail_rows' => $rows,
];
}
protected function reportEventiCompleto(): array
{
$eventi = Evento::with(['gruppi', 'responsabili'])
->orderBy('data_specifica', 'desc')
->get();
$rows = $eventi->map(function ($e) {
return [
'nome' => $e->nome_evento,
'data' => $e->data_specifica?->format('d/m/Y') ?? '-',
'tipo_evento' => $e->tipo_evento ?? '-',
'ricorrenza' => ucfirst($e->tipo_recorrenza ?? 'singolo'),
'gruppi' => $e->gruppi->pluck('nome')->implode(', ') ?: '-',
'responsabili' => $e->responsabili->pluck('nome_completo')->implode(', ') ?: '-',
'luogo' => $e->luogo_indirizzo ?? '-',
];
})->toArray();
return [
'title' => 'Eventi (dettaglio completo)',
'headers' => ['Evento', 'Data', 'Tipo Evento', 'Ricorrenza', 'Gruppi', 'Responsabili', 'Luogo'],
'rows' => array_map(fn($r) => [$r['nome'], $r['data'], $r['tipo_evento'], $r['ricorrenza'], $r['gruppi'], $r['responsabili'], $r['luogo']], $rows),
'summary' => 'Totale eventi: ' . $eventi->count(),
'detail_rows' => $rows,
];
}
protected function reportContattiCompleto(): array
{
$contatti = Contatto::with('individuo')
->orderBy('tipo')
->orderBy('valore')
->get();
$rows = $contatti->map(function ($c) {
return [
'tipo' => ucfirst($c->tipo),
'valore' => $c->valore,
'etichetta' => $c->etichetta ?? '-',
'primario' => $c->is_primary ? 'Si' : 'No',
'individuo' => $c->individuo?->nome_completo ?? '-',
'codice' => $c->individuo?->codice_id ?? '-',
];
})->toArray();
return [
'title' => 'Contatti (dettaglio completo)',
'headers' => ['Tipo', 'Valore', 'Etichetta', 'Primario', 'Individuo', 'Codice'],
'rows' => array_map(fn($r) => [$r['tipo'], $r['valore'], $r['etichetta'], $r['primario'], $r['individuo'], $r['codice']], $rows),
'summary' => 'Totale contatti: ' . $contatti->count(),
'detail_rows' => $rows,
];
}
protected function runCustomReport(ReportCustom $report): array
{
$tipo = $report->tipo_report;
$config = $report->config ?? [];
@@ -642,17 +951,33 @@ class ReportController extends Controller
];
}
$columns = $config['columns'] ?? [];
$hasRelations = false;
foreach ($columns as $col) {
if (str_contains($col, '.')) {
$hasRelations = true;
$relation = explode('.', $col)[0];
$query->with($relation);
}
}
if (!empty($config['search'])) {
$search = $config['search'];
$query->where(function ($q) use ($search) {
$query->where(function ($q) use ($config, $search) {
foreach ($config['search_columns'] ?? [] as $col) {
$q->orWhere($col, 'like', '%' . $search . '%');
if (!str_contains($col, '.')) {
$q->orWhere($col, 'like', '%' . $search . '%');
}
}
});
}
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'])) {
@@ -661,16 +986,29 @@ class ReportController extends Controller
$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 = [];
foreach ($headers as $col) {
$value = $item->{$col} ?? '';
if ($value instanceof \Carbon\Carbon) {
$value = $value->format('d/m/Y');
foreach ($columns as $col) {
if (str_contains($col, '.')) {
$row[] = ReportColumnRegistry::resolveRelationValue($item, $col);
} else {
$value = $item->{$col} ?? '';
if ($value instanceof \Carbon\Carbon) {
$value = $value->format('d/m/Y');
} elseif (is_bool($value)) {
$value = $value ? 'Si' : 'No';
}
$row[] = $value;
}
$row[] = $value;
}
return $row;
})->toArray();
@@ -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());
}
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ class AdminOnly
return redirect('/login');
}
if (!auth()->user()->isSuperAdmin()) {
if (!auth()->user()->isSuperAdmin() && !auth()->user()->canManage('settings')) {
abort(403, 'Accesso riservato agli amministratori.');
}
+57 -23
View File
@@ -1,28 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
class AppSetting extends Model
{
protected $table = 'app_settings';
protected $fillable = [
'logo',
'logo_small',
'logo_path',
'logo_small_path',
'nome_applicazione',
'nome_organizzazione',
'footer_text',
'app_version',
'dashboard_welcome',
'show_version',
'logo', 'logo_small', 'logo_path', 'logo_small_path',
'nome_applicazione', 'nome_organizzazione',
'footer_text', 'app_version', 'dashboard_welcome', 'show_version',
'backup_path', 'backup_retention_days', 'backup_include_files',
'backup_include_env', 'backup_auto_enabled', 'backup_auto_frequency', 'backup_auto_hour',
'documenti_storage_disk', 'documenti_storage_path',
];
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();
return $setting?->{$key} ?? $default;
}
@@ -73,40 +90,57 @@ class AppSetting extends Model
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
{
if ($this->logo && file_exists(public_path('storage/' . $this->logo))) {
return asset('storage/' . $this->logo);
if ($this->logo && Storage::disk('public')->exists($this->logo)) {
return Storage::disk('public')->url($this->logo);
}
return null;
}
public function getLogoSmallUrlInstance(): ?string
{
if ($this->logo_small && file_exists(public_path('storage/' . $this->logo_small))) {
return asset('storage/' . $this->logo_small);
if ($this->logo_small && Storage::disk('public')->exists($this->logo_small)) {
return Storage::disk('public')->url($this->logo_small);
}
return null;
}
public function getLogoPath(): ?string
{
if ($this->logo_path && file_exists(public_path('storage/' . $this->logo_path))) {
return asset('storage/' . $this->logo_path);
if ($this->logo_path && Storage::disk('public')->exists($this->logo_path)) {
return Storage::disk('public')->url($this->logo_path);
}
if ($this->logo && file_exists(public_path('storage/' . $this->logo))) {
return asset('storage/' . $this->logo);
if ($this->logo && Storage::disk('public')->exists($this->logo)) {
return Storage::disk('public')->url($this->logo);
}
return null;
}
public function getLogoSmallPath(): ?string
{
if ($this->logo_small_path && file_exists(public_path('storage/' . $this->logo_small_path))) {
return asset('storage/' . $this->logo_small_path);
if ($this->logo_small_path && Storage::disk('public')->exists($this->logo_small_path)) {
return Storage::disk('public')->url($this->logo_small_path);
}
if ($this->logo_small && file_exists(public_path('storage/' . $this->logo_small))) {
return asset('storage/' . $this->logo_small);
if ($this->logo_small && Storage::disk('public')->exists($this->logo_small)) {
return Storage::disk('public')->url($this->logo_small);
}
return null;
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class CalendarioConnessione extends Model
{
protected $table = 'calendario_connessioni';
protected $fillable = [
'nome',
'tipo',
'config',
'stato',
'sync_direction',
'sync_interval_minutes',
'last_sync_at',
'last_error_at',
'last_error_message',
'is_active',
'ordine',
];
protected $casts = [
'config' => 'array',
'is_active' => 'boolean',
'sync_interval_minutes' => 'integer',
'last_sync_at' => 'datetime',
'last_error_at' => 'datetime',
'ordine' => 'integer',
];
public function scopeAttivi($query)
{
return $query->where('is_active', true)->orderBy('ordine');
}
public function getDecryptedConfig(): array
{
$config = $this->config;
foreach (['username', 'password', 'client_secret', 'refresh_token', 'api_key'] as $field) {
if (isset($config[$field])) {
try {
$config[$field] = Crypt::decryptString($config[$field]);
} catch (\Exception) {
}
}
}
return $config;
}
public function encryptAndSetConfig(array $config): void
{
foreach (['username', 'password', 'client_secret', 'refresh_token', 'api_key'] as $field) {
if (isset($config[$field]) && $config[$field] !== '' && !str_starts_with($config[$field], 'eyJpdiI')) {
$config[$field] = Crypt::encryptString($config[$field]);
}
}
$this->config = $config;
}
public static function opzioniTipo(): array
{
return ['google_calendar', 'caldav'];
}
public static function etichettaTipo(string $tipo): string
{
return match ($tipo) {
'google_calendar' => 'Google Calendar',
'caldav' => 'CalDAV (Infomaniak/NextCloud)',
default => ucfirst($tipo),
};
}
}
+12 -1
View File
@@ -11,7 +11,8 @@ class Documento extends Model
{
protected $table = 'documenti';
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',
'mime_type', 'dimensione', 'note'
];
@@ -21,6 +22,16 @@ class Documento extends Model
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
{
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);
}
}
+1
View File
@@ -30,6 +30,7 @@ class Evento extends Model
'is_incontro_gruppo',
'luogo_indirizzo',
'luogo_url_maps',
'uid_esterno',
];
protected $casts = [
+3 -1
View File
@@ -10,11 +10,13 @@ class MailingMessaggio extends Model
protected $table = 'mailing_messaggi';
protected $fillable = [
'tenant_id', 'mailing_list_id', 'user_id', 'oggetto', 'corpo',
'canale', 'stato', 'inviato_al', 'totale_destinatari', 'invii_ok', 'invii_falliti'
'canale', 'stato', 'inviato_al', 'totale_destinatari', 'invii_ok', 'invii_falliti',
'log_falliti', 'mittente_nome', 'mittente_email',
];
protected $casts = [
'inviato_al' => 'datetime',
'log_falliti' => 'array',
];
public function tenant(): BelongsTo
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Crypt;
class SenderAccount extends Model
{
protected $table = 'sender_accounts';
protected $fillable = [
'email_address',
'email_name',
'smtp_host',
'smtp_port',
'smtp_encryption',
'smtp_username',
'smtp_password',
'reply_to',
'verify_email',
'note',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
'smtp_port' => 'integer',
];
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function getDecryptedPassword(): string
{
if (empty($this->smtp_password)) {
return '';
}
try {
return Crypt::decryptString($this->smtp_password);
} catch (\Exception $e) {
return $this->smtp_password;
}
}
public function sanitizeName(): string
{
$name = $this->email_name ?? '';
if (filter_var($name, FILTER_VALIDATE_EMAIL)) {
return 'Glastree';
}
$name = preg_replace('/[^\p{L}\p{N}\s]/u', '', $name);
$name = trim($name);
return empty($name) ? 'Glastree' : $name;
}
public function getReplyTo(): ?string
{
if ($this->reply_to) {
return $this->reply_to;
}
if ($this->verify_email) {
return $this->verify_email;
}
return null;
}
public function buildDsn(): string
{
$password = $this->getDecryptedPassword();
$username = $this->smtp_username ?? $this->email_address;
$encryption = $this->smtp_encryption ?? 'tls';
$scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp';
return sprintf(
'%s://%s:%s@%s:%d?encryption=%s',
$scheme,
rawurlencode($username),
rawurlencode($password),
$this->smtp_host ?? 'smtp.gmail.com',
$this->smtp_port ?? 587,
$encryption
);
}
public function buildSymfonyEmail(string $to, string $subject, string $body): \Symfony\Component\Mime\Email
{
$fromName = $this->sanitizeName();
$fromAddress = \Symfony\Component\Mime\Address::create($this->email_address, $fromName);
$email = (new \Symfony\Component\Mime\Email())
->from($fromAddress)
->to($to)
->subject($subject)
->text($body);
$replyTo = $this->getReplyTo();
if ($replyTo) {
$email->replyTo($replyTo);
}
return $email;
}
public function sendEmail(string $to, string $subject, string $body, array $attachmentPaths = []): void
{
$email = $this->buildSymfonyEmail($to, $subject, $body);
foreach ($attachmentPaths as $path) {
$email->attachFromPath($path);
}
$transport = \Symfony\Component\Mailer\Transport::fromDsn($this->buildDsn());
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
$mailer->send($email);
}
public function sendReport(string $subject, string $body): bool
{
if (empty($this->verify_email)) {
return false;
}
try {
$this->sendEmail($this->verify_email, $subject, $body);
return true;
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Sender report email failed', [
'sender' => $this->email_address,
'error' => $e->getMessage(),
]);
return false;
}
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class StorageRepository extends Model
{
protected $table = 'storage_repositories';
protected $fillable = [
'nome',
'tipo',
'config',
'is_active',
'ordine',
];
protected $casts = [
'config' => 'array',
'is_active' => 'boolean',
'ordine' => 'integer',
];
public function documenti()
{
return $this->hasMany(Documento::class, 'repository_id');
}
public function getDecryptedConfig(): array
{
$config = $this->config;
if (isset($config['password'])) {
try {
$config['password'] = Crypt::decryptString($config['password']);
} catch (\Exception) {
}
}
if (isset($config['refresh_token'])) {
try {
$config['refresh_token'] = Crypt::decryptString($config['refresh_token']);
} catch (\Exception) {
}
}
if (isset($config['client_secret'])) {
try {
$config['client_secret'] = Crypt::decryptString($config['client_secret']);
} catch (\Exception) {
}
}
return $config;
}
public function scopeAttivi($query)
{
return $query->where('is_active', true)->orderBy('ordine');
}
public static function opzioniTipo(): array
{
return ['webdav', 'google_drive'];
}
public static function etichettaTipo(string $tipo): string
{
return match ($tipo) {
'webdav' => 'WebDAV',
'google_drive' => 'Google Drive',
default => ucfirst($tipo),
};
}
public static function iconaTipo(string $tipo): string
{
return match ($tipo) {
'webdav' => 'fa-server',
'google_drive' => 'fa-google-drive',
default => 'fa-database',
};
}
}
+69 -3
View File
@@ -7,14 +7,22 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
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\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'])]
#[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'];
@@ -33,7 +41,7 @@ class User extends Authenticatable
public const STATUS_SUSPENDED = 'suspended';
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_READ = 1;
public const LEVEL_FULL = 2;
@@ -156,4 +164,62 @@ class User extends Authenticatable
$this->permissions = $preset->permissions;
return $this;
}
public function sendPasswordResetNotification($token): void
{
$settings = EmailSetting::getActive();
if (!$settings || empty($settings->smtp_host)) {
Log::warning('Impossibile inviare reset password: SMTP non configurato');
return;
}
try {
$smtpUsername = $settings->smtp_username ?: $settings->email_address;
$smtpPassword = $settings->smtp_password
? Crypt::decryptString($settings->smtp_password)
: $settings->getDecryptedPassword();
$encryption = $settings->smtp_encryption ?? 'tls';
$scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp';
$dsn = sprintf(
'%s://%s:%s@%s:%d?encryption=%s',
$scheme,
rawurlencode($smtpUsername),
rawurlencode($smtpPassword),
$settings->smtp_host,
$settings->smtp_port ?? 587,
$encryption
);
$transport = Transport::fromDsn($dsn);
$mailer = new Mailer($transport);
$resetUrl = url('/password/reset/' . $token);
$fromName = $settings->email_name ?? config('app.name');
$fromName = preg_replace('/[^\p{L}\p{N}\s]/u', '', $fromName);
$fromName = trim($fromName) ?: config('app.name');
$email = (new Email())
->from(new Address($settings->email_address, $fromName))
->to($this->email)
->subject('Reset della password - ' . config('app.name'))
->html(view('auth.emails.reset-password', [
'user' => $this,
'token' => $token,
'resetUrl' => $resetUrl,
'appName' => config('app.name'),
])->render());
$mailer->send($email);
Log::info('Password reset email sent', ['email' => $this->email]);
} catch (\Exception $e) {
Log::error('Failed to send password reset email', [
'email' => $this->email,
'error' => $e->getMessage(),
]);
}
}
}
+2 -2
View File
@@ -9,8 +9,8 @@ class VistaReport extends Model
{
protected $table = 'viste_report';
protected $fillable = [
'user_id', 'nome', 'tipo', 'colonne_visibili',
'colonne_ordinamento', 'filtri', 'ricerca'
'user_id', 'nome', 'tipo', 'colonne_visibili',
'colonne_ordinamento', 'filtri', 'ricerca', 'is_default'
];
protected $casts = [
+297
View File
@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\AppSetting;
use App\Models\Notifica;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use ZipArchive;
class BackupService
{
private string $backupDir;
private string $tempDir;
public function __construct()
{
$basePath = AppSetting::getSetting('backup_path', 'backups');
$this->backupDir = storage_path('app/' . ltrim($basePath, '/'));
$this->tempDir = storage_path('app/backup-temp');
}
public function getBackupDir(): string
{
return $this->backupDir;
}
public function list(): array
{
if (!is_dir($this->backupDir)) {
return [];
}
$files = File::files($this->backupDir);
$backups = [];
foreach ($files as $file) {
if ($file->getExtension() !== 'zip') {
continue;
}
$manifest = $this->readManifest($file->getPathname());
$backups[] = [
'filename' => $file->getFilename(),
'pathname' => $file->getPathname(),
'size' => $file->getSize(),
'size_formatted' => $this->formatBytes($file->getSize()),
'last_modified' => $file->getMTime(),
'manifest' => $manifest,
];
}
usort($backups, fn(array $a, array $b) => $b['last_modified'] - $a['last_modified']);
return $backups;
}
public function run(): array
{
$result = ['success' => true, 'message' => '', 'filename' => ''];
try {
$this->cleanTemp();
File::ensureDirectoryExists($this->tempDir, 0755, true);
File::ensureDirectoryExists($this->backupDir, 0755, true);
$timestamp = now()->format('Y-m-d_H-i-s');
$appName = str_replace(' ', '_', AppSetting::getAppName() ?? 'app');
$filename = $appName . '_' . $timestamp . '.zip';
$filepath = $this->backupDir . '/' . $filename;
$steps = [];
$steps[] = $this->backupDatabase();
$steps[] = $this->backupEnv();
$steps[] = $this->backupFiles();
$this->writeManifest($timestamp);
$zipResult = $this->createZip($filepath);
if (!$zipResult) {
throw new RuntimeException('Errore durante la creazione del file ZIP.');
}
$this->cleanTemp();
$size = file_exists($filepath) ? filesize($filepath) : 0;
Log::info("Backup completato: {$filename} (" . $this->formatBytes($size) . ")");
$this->cleanupOldBackups();
return [
'success' => true,
'message' => 'Backup completato con successo.',
'filename' => $filename,
'size' => $size,
'size_formatted' => $this->formatBytes($size),
];
} catch (\Exception $e) {
$this->cleanTemp();
Log::error('Backup fallito: ' . $e->getMessage());
return [
'success' => false,
'message' => 'Backup fallito: ' . $e->getMessage(),
'filename' => '',
];
}
}
public function download(string $filename): ?string
{
$filepath = $this->backupDir . '/' . basename($filename);
return file_exists($filepath) ? $filepath : null;
}
public function delete(string $filename): bool
{
$filepath = $this->backupDir . '/' . basename($filename);
if (file_exists($filepath)) {
unlink($filepath);
Log::info("Backup eliminato: {$filename}");
return true;
}
return false;
}
public function getConfig(): array
{
return [
'backup_path' => AppSetting::getSetting('backup_path', 'backups'),
'backup_retention_days' => (int) AppSetting::getSetting('backup_retention_days', 30),
'backup_include_files' => (bool) AppSetting::getSetting('backup_include_files', true),
'backup_include_env' => (bool) AppSetting::getSetting('backup_include_env', true),
];
}
public function saveConfig(array $data): void
{
$settings = AppSetting::first() ?? new AppSetting();
$settings->backup_path = trim($data['backup_path'] ?? 'backups', '/');
$settings->backup_retention_days = max(1, (int) ($data['backup_retention_days'] ?? 30));
$settings->backup_include_files = !empty($data['backup_include_files']);
$settings->backup_include_env = !empty($data['backup_include_env']);
$settings->save();
}
private function backupDatabase(): string
{
$dbName = config('database.connections.mysql.database');
$dbUser = config('database.connections.mysql.username');
$dbPass = config('database.connections.mysql.password');
$dbHost = config('database.connections.mysql.host');
$dbPort = config('database.connections.mysql.port');
$sqlFile = $this->tempDir . '/database.sql';
$command = sprintf(
'mysqldump --host=%s --port=%s --user=%s --password=%s --routines --events --single-transaction --quick %s > %s 2>&1',
escapeshellarg($dbHost),
escapeshellarg($dbPort),
escapeshellarg($dbUser),
escapeshellarg($dbPass),
escapeshellarg($dbName),
escapeshellarg($sqlFile)
);
$output = null;
$exitCode = null;
exec($command, $output, $exitCode);
if ($exitCode !== 0 || !file_exists($sqlFile) || filesize($sqlFile) === 0) {
throw new RuntimeException('mysqldump fallito: ' . implode("\n", (array) $output));
}
return 'database.sql (' . $this->formatBytes(filesize($sqlFile)) . ')';
}
private function backupEnv(): string
{
if (!AppSetting::getSetting('backup_include_env', true)) {
return '.env (escluso)';
}
$envPath = base_path('.env');
$envKeyPath = base_path('.env.key');
if (file_exists($envPath)) {
copy($envPath, $this->tempDir . '/.env');
}
if (file_exists($envKeyPath)) {
copy($envKeyPath, $this->tempDir . '/.env.key');
}
return '.env copiato';
}
private function backupFiles(): string
{
if (!AppSetting::getSetting('backup_include_files', true)) {
return 'files (esclusi)';
}
$filesDir = AppSetting::getDocumentiStorageAbsolutePath();
$targetDir = $this->tempDir . '/files';
if (!is_dir($filesDir)) {
return 'files (cartella non trovata)';
}
File::ensureDirectoryExists($targetDir, 0755, true);
File::copyDirectory($filesDir, $targetDir);
return 'files copiati';
}
private function writeManifest(string $timestamp): void
{
$manifest = [
'created_at' => $timestamp,
'app_version' => AppSetting::getAppVersion() ?? 'unknown',
'app_name' => AppSetting::getAppName() ?? 'Glastree',
'db_driver' => config('database.default'),
'db_name' => config('database.connections.mysql.database'),
'php_version' => PHP_VERSION,
'laravel_version' => app()->version(),
];
File::put($this->tempDir . '/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT));
}
private function readManifest(string $zipPath): ?array
{
$zip = new ZipArchive();
if ($zip->open($zipPath) === true) {
$manifestContent = $zip->getFromName('manifest.json');
$zip->close();
if ($manifestContent !== false) {
return json_decode($manifestContent, true);
}
}
return null;
}
private function createZip(string $filepath): bool
{
$zip = new ZipArchive();
if ($zip->open($filepath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
return false;
}
$files = File::allFiles($this->tempDir);
foreach ($files as $file) {
$relativePath = substr($file->getPathname(), strlen($this->tempDir) + 1);
$zip->addFile($file->getPathname(), $relativePath);
}
return $zip->close();
}
private function cleanTemp(): void
{
if (is_dir($this->tempDir)) {
File::deleteDirectory($this->tempDir);
}
}
private function cleanupOldBackups(): void
{
$retentionDays = (int) AppSetting::getSetting('backup_retention_days', 30);
$cutoff = now()->subDays($retentionDays)->timestamp;
foreach (File::files($this->backupDir) as $file) {
if ($file->getExtension() === 'zip' && $file->getMTime() < $cutoff) {
unlink($file->getPathname());
Log::info("Backup eliminato per retention: " . $file->getFilename());
}
}
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
}
+389
View File
@@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\CalendarioConnessione;
use App\Models\Evento;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class CalDavService
{
private const CALDAV_NS = 'urn:ietf:params:xml:ns:caldav';
private const DAV_NS = 'DAV:';
public function testConnection(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$client = $this->buildClient($config);
try {
$response = $client->request('PROPFIND', $config['url'], [
'headers' => ['Depth' => '0'],
'body' => '<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
<d:prop>
<d:displayname/>
<cs:getctag/>
</d:prop>
</d:propfind>',
]);
if ($response->getStatusCode() < 300) {
return ['success' => true, 'message' => 'Connessione CalDAV riuscita.'];
}
return ['success' => false, 'message' => 'Errore HTTP: ' . $response->getStatusCode()];
} catch (\Exception $e) {
return ['success' => false, 'message' => 'Errore: ' . $e->getMessage()];
}
}
public function listCalendars(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$client = $this->buildClient($config);
try {
$response = $client->request('PROPFIND', $config['url'], [
'headers' => ['Depth' => '1'],
'body' => '<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:resourcetype/>
<d:displayname/>
<c:supported-calendar-component-set/>
</d:prop>
</d:propfind>',
]);
$body = (string) $response->getBody();
return $this->parseCalendarList($body);
} catch (\Exception $e) {
Log::error('CalDAV listCalendars: ' . $e->getMessage());
return [];
}
}
public function syncEvents(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$client = $this->buildClient($config);
$calendarUrl = $config['calendar_url'] ?? $config['url'];
$imported = 0;
$exported = 0;
$errors = [];
$start = Carbon::now()->subYear()->format('Ymd\THis\Z');
$end = Carbon::now()->addYear()->format('Ymd\THis\Z');
$direction = $connessione->sync_direction ?? 'bidirectional';
try {
if ($direction === 'import' || $direction === 'bidirectional') {
$result = $this->fetchRemoteEvents($client, $calendarUrl, $start, $end);
$imported = $this->importEvents($result, $errors);
}
if ($direction === 'export' || $direction === 'bidirectional') {
$localEvents = Evento::where(function ($q) use ($connessione) {
$q->whereNotNull('uid_esterno')
->orWhereNull('uid_esterno');
})->get();
$exported = $this->exportEvents($client, $calendarUrl, $localEvents, $connessione, $errors);
}
} catch (\Exception $e) {
$errors[] = $e->getMessage();
Log::error('CalDAV syncEvents: ' . $e->getMessage());
}
return [
'success' => empty($errors),
'imported' => $imported,
'exported' => $exported,
'errors' => $errors,
];
}
private function fetchRemoteEvents($client, string $url, string $start, string $end): array
{
$body = '<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="' . $start . '" end="' . $end . '"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>';
$response = $client->request('REPORT', $url, [
'headers' => ['Depth' => '1', 'Content-Type' => 'application/xml; charset=utf-8'],
'body' => $body,
]);
return $this->parseCalendarData((string) $response->getBody());
}
private function importEvents(array $remoteEvents, array &$errors): int
{
$count = 0;
foreach ($remoteEvents as $event) {
try {
$vcalendar = \Sabre\VObject\Reader::read($event['data']);
foreach ($vcalendar->VEVENT as $vevent) {
$uid = (string) ($vevent->UID ?? Str::uuid());
$existing = Evento::where('uid_esterno', $uid)->first();
if ($existing) {
continue;
}
$summary = (string) ($vevent->SUMMARY ?? 'Evento importato');
$description = (string) ($vevent->DESCRIPTION ?? '');
$location = (string) ($vevent->LOCATION ?? '');
$dtstart = $vevent->DTSTART ? $this->parseDateTime($vevent->DTSTART) : null;
$dtend = $vevent->DTEND ? $this->parseDateTime($vevent->DTEND) : null;
$data = [
'nome_evento' => $summary,
'descrizione_evento' => mb_substr($description, 0, 255),
'tipo_recorrenza' => 'singolo',
'luogo_indirizzo' => $location,
'uid_esterno' => $uid,
];
if ($dtstart) {
$data['data_specifica'] = $dtstart->format('Y-m-d');
$data['ora_inizio'] = $dtstart->format('H:i');
}
if ($dtend && $dtstart) {
$data['durata_minuti'] = (int) $dtstart->diffInMinutes($dtend);
}
$rrule = $vevent->RRULE ? (string) $vevent->RRULE : null;
if ($rrule) {
$parsed = $this->parseRRule($rrule);
$data = array_merge($data, $parsed);
}
Evento::create($data);
$count++;
}
} catch (\Exception $e) {
$errors[] = 'Errore parsing evento: ' . $e->getMessage();
}
}
return $count;
}
private function exportEvents($client, string $url, iterable $localEvents, CalendarioConnessione $connessione, array &$errors): int
{
$count = 0;
$existingUids = $this->getExistingEventUids($client, $url);
foreach ($localEvents as $evento) {
try {
if ($evento->uid_esterno && !in_array($evento->uid_esterno, $existingUids)) {
continue;
}
$uid = $evento->uid_esterno ?? ('local-' . $evento->id . '-' . Str::uuid());
$icsData = $this->buildIcsForExport($evento, $uid);
$eventUrl = rtrim($url, '/') . '/' . $uid . '.ics';
$response = $client->request('PUT', $eventUrl, [
'headers' => ['Content-Type' => 'text/calendar; charset=utf-8'],
'body' => $icsData,
]);
if ($response->getStatusCode() < 300) {
if (!$evento->uid_esterno) {
$evento->updateQuietly(['uid_esterno' => $uid]);
}
$count++;
}
} catch (\Exception $e) {
$errors[] = 'Errore export ' . $evento->nome_evento . ': ' . $e->getMessage();
}
}
return $count;
}
private function getExistingEventUids($client, string $url): array
{
try {
$response = $client->request('REPORT', $url, [
'headers' => ['Depth' => '1'],
'body' => '<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<d:resourcetype/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT"/>
</c:comp-filter>
</c:filter>
</c:calendar-query>',
]);
$body = (string) $response->getBody();
$xml = simplexml_load_string($body);
$xml->registerXPathNamespace('d', self::DAV_NS);
$uids = [];
foreach ($xml->xpath('//d:href') as $href) {
$path = (string) $href;
$name = basename($path);
if (str_ends_with($name, '.ics')) {
$uids[] = str_replace('.ics', '', $name);
}
}
return $uids;
} catch (\Exception) {
return [];
}
}
private function buildIcsForExport(Evento $evento, string $uid): string
{
$service = app(IcsExportService::class);
return $service->generateSingle($evento);
}
private function parseCalendarList(string $xml): array
{
$calendars = [];
try {
$dom = new \DOMDocument();
$dom->loadXML($xml);
$xpath = new \DOMXPath($dom);
$xpath->registerNamespace('d', self::DAV_NS);
$xpath->registerNamespace('c', self::CALDAV_NS);
$responses = $xpath->query('//d:multistatus/d:response');
foreach ($responses as $response) {
$href = $xpath->query('d:href', $response)->item(0)?->textContent ?? '';
$displayName = $xpath->query('d:propstat/d:prop/d:displayname', $response)->item(0)?->textContent ?? $href;
$hasCalendar = false;
$resourcetypes = $xpath->query('d:propstat/d:prop/d:resourcetype/*', $response);
foreach ($resourcetypes as $type) {
if ($type->localName === 'calendar' && $type->namespaceURI === self::CALDAV_NS) {
$hasCalendar = true;
break;
}
}
if ($hasCalendar && $href) {
$calendars[] = [
'href' => $href,
'displayname' => $displayName,
];
}
}
} catch (\Exception $e) {
Log::error('CalDAV parseCalendarList: ' . $e->getMessage());
}
return $calendars;
}
private function parseCalendarData(string $xml): array
{
$events = [];
try {
$dom = new \DOMDocument();
$dom->loadXML($xml);
$xpath = new \DOMXPath($dom);
$xpath->registerNamespace('d', self::DAV_NS);
$xpath->registerNamespace('c', self::CALDAV_NS);
$responses = $xpath->query('//d:multistatus/d:response');
foreach ($responses as $response) {
$dataNodes = $xpath->query('d:propstat/d:prop/c:calendar-data', $response);
foreach ($dataNodes as $node) {
$events[] = ['data' => $node->textContent];
}
}
} catch (\Exception $e) {
Log::error('CalDAV parseCalendarData: ' . $e->getMessage());
}
return $events;
}
private function parseDateTime($dt): ?Carbon
{
try {
if ($dt instanceof \Sabre\VObject\Property\ICalendar\DateTime) {
$dtValue = $dt->getDateTime();
return Carbon::instance($dtValue);
}
} catch (\Exception) {
}
return null;
}
private function parseRRule(string $rrule): array
{
$result = [];
try {
$parts = explode(';', $rrule);
foreach ($parts as $part) {
[$key, $value] = explode('=', $part, 2);
if ($key === 'FREQ') {
$result['tipo_recorrenza'] = match (strtolower($value)) {
'weekly' => 'settimanale',
'monthly' => 'mensile',
'yearly' => 'annuale',
default => 'singolo',
};
} elseif ($key === 'BYDAY' && isset($result['tipo_recorrenza'])) {
$dayMap = ['SU' => 0, 'MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6];
$byDay = strtoupper($value);
if (preg_match('/^(-?\d+)?([A-Z]+)$/', $byDay, $m)) {
$result['giorno_settimana'] = $dayMap[$m[2]] ?? null;
if (!empty($m[1]) && $result['tipo_recorrenza'] === 'mensile') {
$result['occorrenza_mese'] = $m[1] . ',' . ($dayMap[$m[2]] ?? '1');
}
}
} elseif ($key === 'BYMONTHDAY') {
$result['giorno_mese'] = (int) $value;
} elseif ($key === 'BYMONTH') {
if ($result['tipo_recorrenza'] ?? '' === 'annuale') {
$result['mese_annuale'] = (int) $value;
} else {
$result['mesi_recorrenza'] = explode(',', $value);
}
}
}
} catch (\Exception) {
}
return $result;
}
private function buildClient(array $config): \GuzzleHttp\Client
{
$options = [
'verify' => false,
'timeout' => 30,
'http_errors' => false,
];
if (!empty($config['username']) && !empty($config['password'])) {
$options['auth'] = [$config['username'], $config['password'], 'basic'];
}
return new \GuzzleHttp\Client($options);
}
}
+350
View File
@@ -0,0 +1,350 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\CalendarioConnessione;
use App\Models\Evento;
use Carbon\Carbon;
use Google\Client as GoogleClient;
use Google\Service\Calendar as GoogleCalendar;
use Google\Service\Calendar\Event as GoogleEvent;
use Illuminate\Support\Facades\Log;
class GoogleCalendarSyncService
{
private ?GoogleCalendar $calendarService = null;
private ?string $calendarId = null;
public function testConnection(CalendarioConnessione $connessione): array
{
try {
$client = $this->buildClient($connessione);
$service = new GoogleCalendar($client);
$calendarList = $service->calendarList->listCalendarList();
return ['success' => true, 'message' => 'Connessione Google Calendar riuscita. Trovati ' . count($calendarList->getItems()) . ' calendari.'];
} catch (\Exception $e) {
return ['success' => false, 'message' => 'Errore: ' . $e->getMessage()];
}
}
public function listCalendars(CalendarioConnessione $connessione): array
{
try {
$client = $this->buildClient($connessione);
$service = new GoogleCalendar($client);
$calendarList = $service->calendarList->listCalendarList();
$calendars = [];
foreach ($calendarList->getItems() as $cal) {
$calendars[] = [
'id' => $cal->getId(),
'summary' => $cal->getSummary(),
];
}
return $calendars;
} catch (\Exception $e) {
Log::error('GoogleCalendar listCalendars: ' . $e->getMessage());
return [];
}
}
public function syncEvents(CalendarioConnessione $connessione): array
{
$config = $connessione->getDecryptedConfig();
$imported = 0;
$exported = 0;
$errors = [];
try {
$client = $this->buildClient($connessione);
$this->calendarService = new GoogleCalendar($client);
$this->calendarId = $config['calendar_id'] ?? 'primary';
$direction = $connessione->sync_direction ?? 'bidirectional';
if ($direction === 'import' || $direction === 'bidirectional') {
$result = $this->importRemoteEvents($connessione);
$imported = $result['count'];
foreach ($result['errors'] as $err) {
$errors[] = $err;
}
}
if ($direction === 'export' || $direction === 'bidirectional') {
$result = $this->exportLocalEvents($connessione);
$exported = $result['count'];
foreach ($result['errors'] as $err) {
$errors[] = $err;
}
}
} catch (\Exception $e) {
$errors[] = $e->getMessage();
Log::error('GoogleCalendar syncEvents: ' . $e->getMessage());
}
return [
'success' => empty($errors),
'imported' => $imported,
'exported' => $exported,
'errors' => $errors,
];
}
private function importRemoteEvents(CalendarioConnessione $connessione): array
{
$count = 0;
$errors = [];
$service = $this->calendarService;
try {
$timeMin = Carbon::now()->subYear()->toRfc3339String();
$timeMax = Carbon::now()->addYear()->toRfc3339String();
$optParams = [
'timeMin' => $timeMin,
'timeMax' => $timeMax,
'singleEvents' => true,
'orderBy' => 'startTime',
];
$events = $service->events->listEvents($this->calendarId, $optParams);
while (true) {
foreach ($events->getItems() as $event) {
try {
$uid = $event->getId();
$existing = Evento::where('uid_esterno', $uid)->first();
if ($existing) {
continue;
}
$summary = $event->getSummary() ?? 'Evento importato';
$description = strip_tags($event->getDescription() ?? '');
$location = $event->getLocation() ?? '';
$start = $event->getStart();
$end = $event->getEnd();
$data = [
'nome_evento' => mb_substr($summary, 0, 255),
'descrizione_evento' => mb_substr($description, 0, 255),
'tipo_recorrenza' => 'singolo',
'luogo_indirizzo' => $location,
'uid_esterno' => $uid,
];
if ($start && $start->getDateTime()) {
$dt = Carbon::parse($start->getDateTime());
$data['data_specifica'] = $dt->format('Y-m-d');
$data['ora_inizio'] = $dt->format('H:i');
} elseif ($start && $start->getDate()) {
$data['data_specifica'] = $start->getDate();
}
if ($end && $start && $end->getDateTime() && $start->getDateTime()) {
$data['durata_minuti'] = (int) Carbon::parse($start->getDateTime())->diffInMinutes(Carbon::parse($end->getDateTime()));
}
if ($event->getRecurrence()) {
foreach ($event->getRecurrence() as $rrule) {
if (str_starts_with($rrule, 'RRULE:')) {
$parsed = $this->parseRRule(substr($rrule, 6));
$data = array_merge($data, $parsed);
}
}
}
Evento::create($data);
$count++;
} catch (\Exception $e) {
$errors[] = 'Errore import evento: ' . $e->getMessage();
}
}
$pageToken = $events->getNextPageToken();
if ($pageToken) {
$optParams['pageToken'] = $pageToken;
$events = $service->events->listEvents($this->calendarId, $optParams);
} else {
break;
}
}
} catch (\Exception $e) {
$errors[] = 'Errore import Google Calendar: ' . $e->getMessage();
}
return ['count' => $count, 'errors' => $errors];
}
private function exportLocalEvents(CalendarioConnessione $connessione): array
{
$count = 0;
$errors = [];
$service = $this->calendarService;
try {
$localEvents = Evento::all();
$remoteEventMap = $this->getRemoteEventMap($service);
foreach ($localEvents as $evento) {
try {
$googleEvent = new GoogleEvent();
$googleEvent->setSummary($evento->nome_evento);
if ($evento->descrizione_evento) {
$googleEvent->setDescription($evento->descrizione_evento);
}
if ($evento->luogo_indirizzo) {
$googleEvent->setLocation($evento->luogo_indirizzo);
}
$startData = $this->buildGoogleDateTime($evento, 'start');
$endData = $this->buildGoogleDateTime($evento, 'end');
$googleEvent->setStart($startData);
$googleEvent->setEnd($endData);
if ($evento->tipo_recorrenza !== 'singolo') {
$rrule = $this->buildGoogleRRule($evento);
if ($rrule) {
$googleEvent->setRecurrence(['RRULE:' . $rrule]);
}
}
if ($evento->uid_esterno && isset($remoteEventMap[$evento->uid_esterno])) {
$service->events->update($this->calendarId, $evento->uid_esterno, $googleEvent);
} else {
$created = $service->events->insert($this->calendarId, $googleEvent);
if (!$evento->uid_esterno) {
$evento->updateQuietly(['uid_esterno' => $created->getId()]);
}
}
$count++;
} catch (\Exception $e) {
$errors[] = 'Errore export ' . $evento->nome_evento . ': ' . $e->getMessage();
}
}
} catch (\Exception $e) {
$errors[] = 'Errore export Google Calendar: ' . $e->getMessage();
}
return ['count' => $count, 'errors' => $errors];
}
private function getRemoteEventMap(GoogleCalendar $service): array
{
$map = [];
try {
$events = $service->events->listEvents($this->calendarId);
foreach ($events->getItems() as $event) {
$map[$event->getId()] = true;
}
} catch (\Exception) {
}
return $map;
}
private function buildGoogleDateTime(Evento $evento, string $type): \Google\Service\Calendar\EventDateTime
{
$dt = new \Google\Service\Calendar\EventDateTime();
if ($type === 'start') {
if ($evento->data_specifica) {
$date = $evento->data_specifica instanceof Carbon ? $evento->data_specifica : Carbon::parse($evento->data_specifica);
if ($evento->ora_inizio) {
$time = $evento->ora_inizio instanceof Carbon ? $evento->ora_inizio : Carbon::parse($evento->ora_inizio);
$dt->setDateTime($date->format('Y-m-d') . 'T' . $time->format('H:i:s'));
$dt->setTimeZone('Europe/Rome');
} else {
$dt->setDate($date->format('Y-m-d'));
}
}
} else {
if ($evento->data_specifica) {
$date = $evento->data_specifica instanceof Carbon ? $evento->data_specifica : Carbon::parse($evento->data_specifica);
$durata = (int) ($evento->durata_minuti ?? 60);
if ($evento->ora_inizio) {
$time = $evento->ora_inizio instanceof Carbon ? $evento->ora_inizio : Carbon::parse($evento->ora_inizio);
$endTime = $time->copy()->addMinutes($durata);
$dt->setDateTime($date->format('Y-m-d') . 'T' . $endTime->format('H:i:s'));
$dt->setTimeZone('Europe/Rome');
} else {
$dt->setDate($date->copy()->addDay()->format('Y-m-d'));
}
}
}
return $dt;
}
private function buildGoogleRRule(Evento $evento): ?string
{
$dayMap = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
return match ($evento->tipo_recorrenza) {
'settimanale' => $evento->giorno_settimana !== null
? 'FREQ=WEEKLY;BYDAY=' . ($dayMap[(int) $evento->giorno_settimana] ?? 'MO')
: null,
'mensile' => $evento->occorrenza_mese
? 'FREQ=MONTHLY;BYSETPOS=' . $evento->occorrenza_mese
: null,
'annuale' => $evento->mese_annuale
? 'FREQ=YEARLY;BYMONTH=' . $evento->mese_annuale
: null,
default => null,
};
}
private function parseRRule(string $rrule): array
{
$result = [];
$parts = explode(';', $rrule);
foreach ($parts as $part) {
[$key, $value] = explode('=', $part, 2);
if ($key === 'FREQ') {
$result['tipo_recorrenza'] = match (strtolower($value)) {
'weekly' => 'settimanale',
'monthly' => 'mensile',
'yearly' => 'annuale',
default => 'singolo',
};
} elseif ($key === 'BYDAY') {
$dayMap = ['SU' => 0, 'MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6];
if (preg_match('/^(-?\d+)?([A-Z]+)$/', $value, $m)) {
$result['giorno_settimana'] = $dayMap[$m[2]] ?? null;
if (!empty($m[1])) {
$result['occorrenza_mese'] = $m[1] . ',' . ($dayMap[$m[2]] ?? '1');
}
}
} elseif ($key === 'BYMONTHDAY') {
$result['giorno_mese'] = (int) $value;
} elseif ($key === 'BYMONTH') {
$result['mese_annuale'] = (int) $value;
}
}
return $result;
}
private function buildClient(CalendarioConnessione $connessione): GoogleClient
{
$config = $connessione->getDecryptedConfig();
$client = new GoogleClient();
$client->setApplicationName(config('app.name'));
$client->setScopes([GoogleCalendar::CALENDAR]);
$client->setAuthConfig([
'web' => [
'client_id' => $config['client_id'] ?? '',
'client_secret' => $config['client_secret'] ?? '',
],
]);
$client->setAccessType('offline');
$client->setPrompt('consent');
if (!empty($config['refresh_token'])) {
$client->fetchAccessTokenWithRefreshToken($config['refresh_token']);
}
return $client;
}
}
+212
View File
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Evento;
use Carbon\Carbon;
class IcsExportService
{
private const DATETIME_FORMAT = 'Ymd\THis';
private const DATE_FORMAT = 'Ymd';
private const TIMEZONE = 'Europe/Rome';
private const DAY_MAP = [
0 => 'SU',
1 => 'MO',
2 => 'TU',
3 => 'WE',
4 => 'TH',
5 => 'FR',
6 => 'SA',
];
public function generateSingle(Evento $evento): string
{
return $this->generateCollection(collect([$evento]));
}
public function generateCollection(iterable $eventi): string
{
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Glastree//Eventi//IT',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:' . config('app.name') . ' - Eventi',
];
foreach ($eventi as $evento) {
array_push($lines, ...$this->buildVEvent($evento));
}
$lines[] = 'END:VCALENDAR';
return $this->fold(implode("\r\n", $lines));
}
private function buildVEvent(Evento $evento): array
{
$uid = 'evento-' . $evento->id . '@' . preg_replace('/[^a-z0-9]/', '', strtolower(config('app.name')));
$lines = [
'BEGIN:VEVENT',
'UID:' . $uid,
'DTSTAMP:' . now()->format(self::DATETIME_FORMAT) . 'Z',
];
if ($evento->isSingolo() && $evento->data_specifica) {
$date = $evento->data_specifica instanceof Carbon
? $evento->data_specifica
: Carbon::parse($evento->data_specifica);
if ($evento->ora_inizio) {
$start = $date->copy()->setTimeFrom($evento->ora_inizio instanceof Carbon ? $evento->ora_inizio : Carbon::parse($evento->ora_inizio));
$durata = $evento->durata_minuti ?? 60;
$end = $start->copy()->addMinutes((int) $durata);
$lines[] = 'DTSTART;TZID=' . self::TIMEZONE . ':' . $start->format(self::DATETIME_FORMAT);
$lines[] = 'DTEND;TZID=' . self::TIMEZONE . ':' . $end->format(self::DATETIME_FORMAT);
} else {
$lines[] = 'DTSTART;VALUE=DATE:' . $date->format(self::DATE_FORMAT);
$lines[] = 'DTEND;VALUE=DATE:' . $date->copy()->addDay()->format(self::DATE_FORMAT);
}
} else {
$rrule = $this->buildRRule($evento);
if ($rrule) {
$lines[] = $rrule;
}
if ($evento->ora_inizio) {
$time = $evento->ora_inizio instanceof Carbon ? $evento->ora_inizio : Carbon::parse($evento->ora_inizio);
$lines[] = 'DTSTART;TZID=' . self::TIMEZONE . ':' . $time->format('His');
$durata = $evento->durata_minuti ?? 60;
$endTime = $time->copy()->addMinutes((int) $durata);
$lines[] = 'DTEND;TZID=' . self::TIMEZONE . ':' . $endTime->format('His');
}
}
$lines[] = 'SUMMARY:' . $this->escapeText($evento->nome_evento);
if ($evento->descrizione_evento || $evento->descrizione) {
$desc = $evento->descrizione_evento
? ($evento->descrizione ? $evento->descrizione_evento . '\n\n' . $evento->descrizione : $evento->descrizione_evento)
: $evento->descrizione;
$lines[] = 'DESCRIPTION:' . $this->escapeText($desc);
}
if ($evento->luogo_indirizzo) {
$lines[] = 'LOCATION:' . $this->escapeText($evento->luogo_indirizzo);
}
$lines[] = 'END:VEVENT';
return $lines;
}
private function buildRRule(Evento $evento): ?string
{
return match ($evento->tipo_recorrenza) {
'settimanale' => $this->rruleSettimanale($evento),
'mensile' => $this->rruleMensile($evento),
'annuale' => $this->rruleAnnuale($evento),
'altro' => $this->rruleAltro($evento),
default => null,
};
}
private function rruleSettimanale(Evento $evento): ?string
{
if ($evento->giorno_settimana === null) {
return null;
}
return 'RRULE:FREQ=WEEKLY;BYDAY=' . (self::DAY_MAP[(int) $evento->giorno_settimana] ?? 'MO');
}
private function rruleMensile(Evento $evento): ?string
{
if (!$evento->occorrenza_mese) {
return null;
}
$parts = explode(',', $evento->occorrenza_mese);
if (count($parts) !== 2) {
return null;
}
$occorrenza = (int) $parts[0];
$giorno = (int) $parts[1];
$byDay = self::DAY_MAP[$giorno] ?? 'MO';
$rrule = 'RRULE:FREQ=MONTHLY;BYSETPOS=' . $occorrenza . ';BYDAY=' . $byDay;
if ($evento->mesi_recorrenza) {
$mesi = explode(',', $evento->mesi_recorrenza);
$rrule .= ';BYMONTH=' . implode(',', $mesi);
}
return $rrule;
}
private function rruleAnnuale(Evento $evento): ?string
{
if (!$evento->mese_annuale) {
return null;
}
$rrule = 'RRULE:FREQ=YEARLY;BYMONTH=' . $evento->mese_annuale;
$giorno = (int) ($evento->giorno_mese ?? 1);
$rrule .= ';BYMONTHDAY=' . $giorno;
return $rrule;
}
private function rruleAltro(Evento $evento): ?string
{
return $this->rruleSettimanale($evento);
}
private function escapeText(?string $text): string
{
if ($text === null) {
return '';
}
$text = str_replace(
['\\', ';', ',', "\n"],
['\\\\', '\\;', '\\,', '\\n'],
$text
);
return $text;
}
private function fold(string $ics): string
{
$lines = explode("\r\n", $ics);
$folded = [];
foreach ($lines as $line) {
while (mb_strlen($line) > 75) {
$folded[] = mb_substr($line, 0, 75);
$line = ' ' . mb_substr($line, 75);
}
$folded[] = $line;
}
return implode("\r\n", $folded);
}
public function getFilename(Evento $evento): string
{
$name = str_replace(['/', '\\', ' '], '_', $evento->nome_evento);
return $name . '.ics';
}
public function getCollectionFilename(): string
{
return 'eventi_' . now()->format('Ymd_His') . '.ics';
}
}
+364
View File
@@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\StorageRepository;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use League\Flysystem\Filesystem;
use League\Flysystem\WebDAV\WebDAVAdapter;
use As247\Flysystem\GoogleDrive\GoogleDriveAdapter;
use Sabre\DAV\Client as WebDAVClient;
class StorageRepositoryService
{
public function buildFilesystem(StorageRepository $repo): ?Filesystem
{
try {
$config = $repo->getDecryptedConfig();
// Log only the keys of the config (avoid sensitive values)
try {
Log::debug("StorageRepository: config keys for repo #{$repo->id}: " . json_encode(array_keys($config)));
} catch (\Exception) {
// ignore logging errors
}
return match ($repo->tipo) {
'webdav' => $this->buildWebDAV($config),
'google_drive' => $this->buildGoogleDrive($config),
default => null,
};
} catch (\Exception $e) {
Log::error("StorageRepository: failed to build filesystem for repo #{$repo->id} ({$repo->nome}): " . $e->getMessage());
return null;
}
}
public function testConnection(StorageRepository $repo): array
{
try {
$config = $repo->getDecryptedConfig();
if ($repo->tipo === 'google_drive') {
if (empty($config['client_id']) || empty($config['client_secret']) || empty($config['refresh_token'])) {
return ['success' => false, 'message' => 'Configurazione incompleta: client_id, client_secret e refresh_token sono necessari per Google Drive.'];
}
$apiError = $this->checkGoogleDriveApiStatus($config);
if ($apiError) {
return ['success' => false, 'message' => $apiError];
}
}
$filesystem = $this->buildFilesystem($repo);
if (!$filesystem) {
return ['success' => false, 'message' => 'Impossibile costruire il filesystem per questo tipo di repository.'];
}
$rootPath = $this->normalizePath('/');
$contents = $filesystem->listContents($rootPath, false)->toArray();
return ['success' => true, 'message' => 'Connessione riuscita. ' . count($contents) . ' elementi trovati nella root.'];
} catch (\Exception $e) {
Log::error("StorageRepository: test connection failed for repo #{$repo->id} ({$repo->nome}): " . $e->getMessage());
return ['success' => false, 'message' => 'Errore di connessione: ' . $e->getMessage()];
}
}
private function checkGoogleDriveApiStatus(array $config): ?string
{
try {
$client = new \Google_Client();
$client->setClientId($config['client_id']);
$client->setClientSecret($config['client_secret']);
$client->addScope(\Google_Service_Drive::DRIVE);
$token = $client->refreshToken($config['refresh_token']);
$client->setAccessToken($token);
$service = new \Google_Service_Drive($client);
$rootId = $config['root_folder_id'] ?? 'root';
$service->files->get($rootId, ['fields' => 'id']);
return null;
} catch (\Google\Service\Exception $e) {
$errors = $e->getErrors();
$reason = $errors[0]['reason'] ?? '';
$message = $errors[0]['message'] ?? $e->getMessage();
if ($reason === 'accessNotConfigured' || str_contains($message, 'Drive API has not been used')) {
$projectNumber = explode('-', $config['client_id'] ?? '')[0] ?? '';
return 'Google Drive API non abilitata. Vai su https://console.developers.google.com/apis/api/drive.googleapis.com/overview?project=' . $projectNumber . ' e clicca "Enable".';
}
if ($e->getCode() === 404) {
return 'Cartella root non trovata (ID: ' . ($config['root_folder_id'] ?? 'root') . '). Verifica che il folder ID sia corretto.';
}
return 'Errore API Google Drive: ' . $message;
} catch (\Exception $e) {
return 'Errore di connessione Google Drive: ' . $e->getMessage();
}
}
public function listContents(StorageRepository $repo, string $path = '/'): array
{
try {
$config = $repo->getDecryptedConfig();
if ($repo->tipo === 'google_drive') {
if (empty($config['client_id']) || empty($config['client_secret']) || empty($config['refresh_token'])) {
Log::warning("StorageRepository: listContents failed for repo #{$repo->id} ({$repo->nome}): credenziali Google Drive mancanti");
return [];
}
}
$filesystem = $this->buildFilesystem($repo);
if (!$filesystem) {
return [];
}
$normalizedPath = $this->normalizePath($path);
$items = $filesystem->listContents($normalizedPath, false)->toArray();
$result = [];
foreach ($items as $item) {
$path = method_exists($item, 'path') ? $item->path() : (is_array($item) && isset($item['path']) ? $item['path'] : null);
$type = method_exists($item, 'type') ? $item->type() : (is_array($item) && isset($item['type']) ? $item['type'] : null);
$basename = $path ? basename($path) : '';
$lastModified = method_exists($item, 'lastModified') ? $item->lastModified() : (is_array($item) && isset($item['lastmodified']) ? $item['lastmodified'] : null);
$fileSize = method_exists($item, 'fileSize') ? $item->fileSize() : (is_array($item) && isset($item['filesize']) ? $item['filesize'] : null);
$mimeType = method_exists($item, 'mimeType') ? $item->mimeType() : (is_array($item) && isset($item['mimetype']) ? $item['mimetype'] : null);
$result[] = [
'path' => $path,
'type' => $type,
'basename' => $basename,
'lastModified' => $lastModified,
'fileSize' => $fileSize,
'mimeType' => $mimeType,
];
}
usort($result, fn($a, $b) => ($a['type'] === 'dir' && $b['type'] !== 'dir') ? -1 : (($b['type'] === 'dir' && $a['type'] !== 'dir') ? 1 : strcasecmp($a['basename'], $b['basename'])));
Log::debug("StorageRepository: listContents repo #{$repo->id} tipo={$repo->tipo} path='{$normalizedPath}' results=" . count($result));
return $result;
} catch (\Exception $e) {
Log::error("StorageRepository: listContents failed for repo #{$repo->id} path '{$path}': " . $e->getMessage());
return [];
}
}
public function encryptSensitiveConfig(array $config, string $tipo, array $existingConfig = []): array
{
$sensitiveFields = match ($tipo) {
'webdav' => ['password'],
'google_drive' => ['client_secret', 'refresh_token'],
default => [],
};
foreach ($sensitiveFields as $field) {
if (!empty($config[$field]) && !$this->isEncrypted($config[$field])) {
$config[$field] = Crypt::encryptString($config[$field]);
} elseif (empty($config[$field]) && !empty($existingConfig[$field])) {
$config[$field] = $existingConfig[$field];
}
}
return $config;
}
public function normalizePath(string $path): string
{
$path = trim($path);
if ($path === '' || $path === '\\') {
return '';
}
if ($path === '/') {
return '';
}
return ltrim($path, '/\\');
}
private function isEncrypted(string $value): bool
{
return str_starts_with($value, 'ey') || str_contains($value, ':');
}
private function buildWebDAV(array $config): ?Filesystem
{
$fullBaseUri = rtrim($config['base_uri'] ?? '', '/') . '/';
$parsedUrl = parse_url($fullBaseUri);
$schemeHost = ($parsedUrl['scheme'] ?? 'https') . '://' . ($parsedUrl['host'] ?? 'localhost');
$basePath = rawurldecode($parsedUrl['path'] ?? '/');
$root = '/' . trim($config['root'] ?? '/', '/') . '/';
$prefix = rtrim($basePath, '/') . $root;
$authType = match ($config['auth_type'] ?? 'basic') {
'basic' => WebDAVClient::AUTH_BASIC,
'digest' => WebDAVClient::AUTH_DIGEST,
'ntlm' => WebDAVClient::AUTH_NTLM,
default => WebDAVClient::AUTH_BASIC,
};
$client = new WebDAVClient([
'baseUri' => $schemeHost . '/',
'userName' => $config['username'] ?? '',
'password' => $config['password'] ?? '',
'authType' => $authType,
]);
$adapter = new WebDAVAdapter($client, $prefix);
return new Filesystem($adapter);
}
private function buildGoogleDrive(array $config): ?Filesystem
{
if (empty($config['client_id']) || empty($config['client_secret']) || empty($config['refresh_token'])) {
throw new \InvalidArgumentException('Google Drive richiede client_id, client_secret e refresh_token per l\'autenticazione.');
}
$client = new \Google_Client();
$client->setClientId($config['client_id']);
$client->setClientSecret($config['client_secret']);
$client->addScope(\Google_Service_Drive::DRIVE);
// Refresh token per ottenere un access token valido
$token = $client->refreshToken($config['refresh_token']);
$client->setAccessToken($token);
$service = new \Google_Service_Drive($client);
$rootFolderId = $config['root_folder_id'] ?? 'root';
$adapter = new GoogleDriveAdapter($service, $rootFolderId);
return new Filesystem($adapter);
}
public function readGoogleDriveFile(StorageRepository $repo, string $normalizedPath, string $basename): array
{
$config = $repo->getDecryptedConfig();
$client = new \Google_Client();
$client->setClientId($config['client_id']);
$client->setClientSecret($config['client_secret']);
$client->addScope(\Google_Service_Drive::DRIVE);
$token = $client->refreshToken($config['refresh_token']);
$client->setAccessToken($token);
$service = new \Google_Service_Drive($client);
$rootFolderId = $config['root_folder_id'] ?? 'root';
$fileId = $this->findGoogleDriveFileId($service, $rootFolderId, $normalizedPath);
$file = $service->files->get($fileId, ['fields' => 'id,name,mimeType,size']);
$mimeType = $file->getMimeType();
if ($mimeType && str_starts_with($mimeType, 'application/vnd.google-apps.')) {
return $this->exportGoogleDriveDoc($service, $fileId, $mimeType, $basename);
}
$response = $service->files->get($fileId, ['alt' => 'media']);
$body = $response->getBody();
$stream = $body instanceof \Psr\Http\Message\StreamInterface ? $body->detach() : null;
if (!$stream) {
$stream = fopen('php://temp', 'r+');
fwrite($stream, (string) $body);
rewind($stream);
}
$fileSize = $file->getSize();
return [
'stream' => $stream,
'mimeType' => $mimeType,
'basename' => $basename,
'fileSize' => $fileSize !== null ? (int) $fileSize : null,
];
}
private function exportGoogleDriveDoc(\Google_Service_Drive $service, string $fileId, string $googleMimeType, string $originalBasename): array
{
$exportMap = [
'application/vnd.google-apps.document' => [
'mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'ext' => 'docx',
],
'application/vnd.google-apps.spreadsheet' => [
'mime' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ext' => 'xlsx',
],
'application/vnd.google-apps.presentation' => [
'mime' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'ext' => 'pptx',
],
'application/vnd.google-apps.drawing' => [
'mime' => 'image/png',
'ext' => 'png',
],
];
$format = $exportMap[$googleMimeType] ?? ['mime' => 'application/pdf', 'ext' => 'pdf'];
$response = $service->files->export($fileId, $format['mime'], ['alt' => 'media']);
$body = $response->getBody();
$stream = $body instanceof \Psr\Http\Message\StreamInterface ? $body->detach() : null;
if (!$stream) {
$stream = fopen('php://temp', 'r+');
fwrite($stream, (string) $body);
rewind($stream);
}
$nameWithoutExt = pathinfo($originalBasename, PATHINFO_FILENAME);
$newBasename = $nameWithoutExt . '.' . $format['ext'];
Log::info("StorageRepository: exported Google Drive file {$fileId} from {$googleMimeType} to {$format['mime']} as {$newBasename}");
return [
'stream' => $stream,
'mimeType' => $format['mime'],
'basename' => $newBasename,
'fileSize' => null,
];
}
private function findGoogleDriveFileId(\Google_Service_Drive $service, string $rootFolderId, string $path): string
{
$path = trim($path, '/');
if ($path === '') {
return $rootFolderId;
}
$parts = explode('/', $path);
$currentParentId = $rootFolderId;
foreach ($parts as $name) {
$optParams = [
'q' => sprintf("trashed = false and '%s' in parents and name = '%s'",
str_replace("'", "\\'", $currentParentId),
str_replace("'", "\\'", $name)
),
'fields' => 'files(id, name, mimeType)',
'pageSize' => 1,
];
$results = $service->files->listFiles($optParams);
$files = $results->getFiles();
if (empty($files)) {
throw new \RuntimeException("Elemento non trovato su Google Drive: {$name} (path: {$path})");
}
$currentParentId = $files[0]->getId();
}
return $currentParentId;
}
}
Executable
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
set -euo pipefail
NAME="${APP_NAME:-glastree}"
VERSION="$(date +%Y%m%d_%H%M)"
ARCHIVE="${NAME}-${VERSION}.tar.gz"
echo "==> Installazione dipendenze PHP production..."
composer install --no-dev --optimize-autoloader --no-scripts --quiet
echo " OK"
echo "==> Build asset Vite..."
if [ -f "node_modules/.package-lock.json" ] || [ -d "node_modules" ]; then
npm run build --quiet 2>/dev/null && echo " OK" || echo " (skip — node_modules non presente)"
else
echo " (skip — node_modules assente, build Vite non eseguita)"
fi
echo "==> Pulizia cache..."
rm -rf bootstrap/cache/*.php storage/framework/cache/data/* storage/framework/sessions/* storage/framework/views/* storage/logs/* 2>/dev/null || true
echo " OK"
echo "==> Creazione archivio: ${ARCHIVE}"
tar czf "${ARCHIVE}" \
--exclude='.git' \
--exclude='.gitignore' \
--exclude='.env' \
--exclude='.env.example' \
--exclude='node_modules' \
--exclude='tests' \
--exclude='vendor/bin' \
--exclude='vendor/*/tests' \
--exclude='vendor/*/test' \
--exclude='vendor/*/.git' \
--exclude='vendor/*/docs' \
--exclude='vendor/*/doc' \
--exclude='storage/app/backups' \
--exclude='storage/app/documenti' \
--exclude='storage/app/public' \
--exclude='storage/app/private' \
--exclude='public/storage' \
--exclude='storage/framework/cache/*' \
--exclude='storage/framework/sessions/*' \
--exclude='storage/framework/views/*' \
--exclude='storage/logs/*' \
--exclude='storage/debugbar/*' \
--exclude='bootstrap/cache/*.php' \
--exclude='public/hot' \
--exclude='*.tar.gz' \
--exclude='*.tar' \
--exclude='MEMORY.md' \
--exclude='AGENTS.md' \
--exclude='build-dist.sh' \
--exclude='Dockerfile' \
--exclude='docker-compose.yml' \
--exclude='docker-compose.mysql.yml' \
--exclude='docker-entrypoint.sh' \
--exclude='.dockerignore' \
--warning=no-file-changed \
.
echo ""
echo "============================================"
echo " Archivio creato: ${ARCHIVE}"
echo " Dimensione: $(du -h "${ARCHIVE}" | cut -f1)"
echo "============================================"
echo ""
echo "Per estrarre sul server di destinazione:"
echo " tar xzf ${ARCHIVE}"
echo ""
echo "Poi esegui:"
echo " # 1. Crea directory necessarie (se non esistono già)"
echo ' mkdir -p storage/framework/cache/data storage/framework/sessions storage/framework/views storage/logs bootstrap/cache storage/app/public storage/app/public/logos storage/app/public/documenti/eventi storage/app/backups storage/app/documenti storage/app/private/avatars/gruppi'
echo ""
echo " # 2. Crea file .gitignore in storage/app/public (serve a Laravel)"
echo ' echo "*" > storage/app/public/.gitignore'
echo ' echo "!.gitignore" >> storage/app/public/.gitignore'
echo ""
echo " # 3. Permessi (web server = www-data)"
echo ' chmod -R 775 storage bootstrap/cache'
echo ' chown -R www-data:www-data storage bootstrap/cache'
echo ""
echo " # 4. Configura ambiente"
echo " cp .env.example .env # modifica DB, APP_URL, etc."
echo " php artisan key:generate"
echo ""
echo " # 5. Pulisci cache e ricrea symlink storage (relativo per portabilità)"
echo ' rm -f public/storage'
echo ' ln -sf ../storage/app/public public/storage'
echo " php artisan config:clear"
echo " php artisan route:clear"
echo " php artisan view:clear"
echo ""
echo " # 6. Avvia installazione MySQL"
echo " php install.php"
+4
View File
@@ -7,9 +7,13 @@
"license": "MIT",
"require": {
"php": "^8.3",
"as247/flysystem-google-drive": "^3.0",
"directorytree/imapengine-laravel": "^1.2",
"google/apiclient": "^2.19",
"laravel/framework": "^13.7",
"laravel/tinker": "^3.0",
"league/flysystem-webdav": "^3.31",
"sabre/vobject": "^4.6",
"spatie/laravel-permission": "^7.4",
"webklex/php-imap": "^6.2"
},
Generated
+888 -1
View File
@@ -4,8 +4,112 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "a0180596cd749c38b349eb76b18c390b",
"content-hash": "c594ad5c5f9ff96dc8644231135f694e",
"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",
"version": "0.14.8",
@@ -635,6 +739,70 @@
],
"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",
"version": "v1.4.0",
@@ -706,6 +874,183 @@
],
"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",
"version": "v1.1.4",
@@ -1914,6 +2259,54 @@
},
"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",
"version": "1.16.0",
@@ -2866,6 +3259,55 @@
],
"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",
"version": "1.0.0",
@@ -3555,6 +3997,451 @@
},
"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",
"version": "1.93.0",
+1062
View File
File diff suppressed because it is too large Load Diff
@@ -8,15 +8,19 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('individui', function (Blueprint $table) {
$table->dropColumn('telegram_id');
});
if (Schema::hasColumn('individui', 'telegram_id')) {
Schema::table('individui', function (Blueprint $table) {
$table->dropColumn('telegram_id');
});
}
}
public function down(): void
{
Schema::table('individui', function (Blueprint $table) {
$table->string('telegram_id', 100)->nullable()->after('note');
});
if (!Schema::hasColumn('individui', 'telegram_id')) {
Schema::table('individui', function (Blueprint $table) {
$table->string('telegram_id', 100)->nullable()->after('note');
});
}
}
};
};
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('app_settings')) {
return;
}
Schema::create('app_settings', function (Blueprint $table) {
$table->id();
$table->string('nome_applicazione')->nullable();
$table->string('nome_organizzazione')->nullable();
$table->string('logo')->nullable();
$table->string('logo_small')->nullable();
$table->string('logo_path')->nullable();
$table->string('logo_small_path')->nullable();
$table->string('footer_text')->nullable();
$table->string('app_version')->nullable();
$table->string('dashboard_welcome')->nullable();
$table->boolean('show_version')->default(false);
$table->string('documenti_storage_disk')->default('local');
$table->string('documenti_storage_path')->default('documenti');
$table->string('backup_path')->default('backups');
$table->integer('backup_retention_days')->default(30);
$table->boolean('backup_include_files')->default(true);
$table->boolean('backup_include_env')->default(true);
$table->boolean('backup_auto_enabled')->default(false);
$table->string('backup_auto_frequency', 20)->default('daily');
$table->integer('backup_auto_hour')->default(3);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('app_settings');
}
};
@@ -8,10 +8,21 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('app_settings', function (Blueprint $table) {
$table->string('logo_path')->nullable()->after('logo');
$table->string('logo_small_path')->nullable()->after('logo_small');
});
if (!Schema::hasTable('app_settings')) {
return;
}
if (!Schema::hasColumn('app_settings', 'logo_path')) {
Schema::table('app_settings', function (Blueprint $table) {
$table->string('logo_path')->nullable()->after('logo');
});
}
if (!Schema::hasColumn('app_settings', 'logo_small_path')) {
Schema::table('app_settings', function (Blueprint $table) {
$table->string('logo_small_path')->nullable()->after('logo_small');
});
}
}
public function down(): void
@@ -8,12 +8,24 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('app_settings', function (Blueprint $table) {
$table->string('footer_text')->nullable()->after('nome_organizzazione');
$table->string('app_version')->nullable()->after('footer_text');
$table->string('dashboard_welcome')->nullable()->after('app_version');
$table->boolean('show_version')->default(false)->after('dashboard_welcome');
});
if (!Schema::hasTable('app_settings')) {
return;
}
$columns = ['footer_text', 'app_version', 'dashboard_welcome', 'show_version'];
foreach ($columns as $column) {
if (!Schema::hasColumn('app_settings', $column)) {
Schema::table('app_settings', function (Blueprint $table) use ($column) {
match ($column) {
'footer_text' => $table->string('footer_text')->nullable()->after('nome_organizzazione'),
'app_version' => $table->string('app_version')->nullable()->after('footer_text'),
'dashboard_welcome' => $table->string('dashboard_welcome')->nullable()->after('app_version'),
'show_version' => $table->boolean('show_version')->default(false)->after('dashboard_welcome'),
};
});
}
}
}
public function down(): void
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sender_accounts', function (Blueprint $table) {
$table->id();
$table->string('email_address')->unique();
$table->string('email_name')->nullable();
$table->string('smtp_host')->nullable();
$table->integer('smtp_port')->default(587);
$table->string('smtp_encryption')->default('tls');
$table->string('smtp_username')->nullable();
$table->text('smtp_password')->nullable();
$table->string('reply_to')->nullable();
$table->string('verify_email')->nullable();
$table->text('note')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('sender_accounts');
}
};
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('mailing_messaggi', function (Blueprint $table) {
$table->json('log_falliti')->nullable()->after('invii_falliti');
$table->string('mittente_nome')->nullable()->after('log_falliti');
$table->string('mittente_email')->nullable()->after('mittente_nome');
});
}
public function down(): void
{
Schema::table('mailing_messaggi', function (Blueprint $table) {
$table->dropColumn(['log_falliti', 'mittente_nome', 'mittente_email']);
});
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('documenti_cartelle', function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')->nullable()->constrained('documenti_cartelle')->onDelete('cascade');
$table->string('nome');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('documenti_cartelle');
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('cartella_id')->nullable()->constrained('documenti_cartelle')->onDelete('set null');
});
}
public function down(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->dropForeign(['cartella_id']);
$table->dropColumn('cartella_id');
});
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('app_settings')) {
return;
}
if (!Schema::hasColumn('app_settings', 'documenti_storage_disk')) {
Schema::table('app_settings', function (Blueprint $table) {
$table->string('documenti_storage_disk')->default('local')->after('show_version');
});
}
if (!Schema::hasColumn('app_settings', 'documenti_storage_path')) {
Schema::table('app_settings', function (Blueprint $table) {
$table->string('documenti_storage_path')->default('documenti')->after('documenti_storage_disk');
});
}
}
public function down(): void
{
Schema::table('app_settings', function (Blueprint $table) {
$table->dropColumn(['documenti_storage_disk', 'documenti_storage_path']);
});
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('storage_repositories', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('tipo', 50);
$table->text('config');
$table->boolean('is_active')->default(true);
$table->integer('ordine')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('storage_repositories');
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->foreignId('repository_id')
->nullable()
->constrained('storage_repositories')
->nullOnDelete()
->after('cartella_id');
});
}
public function down(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->dropForeign(['repository_id']);
$table->dropColumn('repository_id');
});
}
};
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->string('storage_disk', 50)->nullable()->after('repository_id');
});
}
public function down(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->dropColumn('storage_disk');
});
}
};
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('app_settings')) {
return;
}
$columns = [
'backup_path' => ['type' => 'string', 'default' => 'backups'],
'backup_retention_days' => ['type' => 'integer', 'default' => 30],
'backup_include_files' => ['type' => 'boolean', 'default' => true],
'backup_include_env' => ['type' => 'boolean', 'default' => true],
'backup_auto_enabled' => ['type' => 'boolean', 'default' => false],
'backup_auto_frequency' => ['type' => 'string', 'length' => 20, 'default' => 'daily'],
'backup_auto_hour' => ['type' => 'integer', 'default' => 3],
];
foreach ($columns as $column => $config) {
if (!Schema::hasColumn('app_settings', $column)) {
Schema::table('app_settings', function (Blueprint $table) use ($column, $config) {
match ($config['type']) {
'string' => $table->string($column, $config['length'] ?? 255)->default($config['default'])->after('show_version'),
'integer' => $table->integer($column)->default($config['default'])->after('show_version'),
'boolean' => $table->boolean($column)->default($config['default'])->after('show_version'),
};
});
}
}
}
public function down(): void
{
Schema::table('app_settings', function (Blueprint $table) {
$table->dropColumn(['backup_path', 'backup_retention_days', 'backup_include_files', 'backup_include_env', 'backup_auto_enabled', 'backup_auto_frequency', 'backup_auto_hour']);
});
}
};
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('calendario_connessioni', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('tipo'); // google_calendar, caldav
$table->text('config'); // JSON
$table->string('stato')->default('disconnesso'); // connesso, disconnesso, errore
$table->string('sync_direction')->default('bidirectional'); // import, export, bidirectional
$table->integer('sync_interval_minutes')->default(60);
$table->timestamp('last_sync_at')->nullable();
$table->timestamp('last_error_at')->nullable();
$table->text('last_error_message')->nullable();
$table->boolean('is_active')->default(true);
$table->integer('ordine')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('calendario_connessioni');
}
};
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('eventi', function (Blueprint $table) {
$table->string('uid_esterno', 500)->nullable()->after('luogo_url_maps');
$table->index('uid_esterno');
});
}
public function down(): void
{
Schema::table('eventi', function (Blueprint $table) {
$table->dropIndex(['uid_esterno']);
$table->dropColumn('uid_esterno');
});
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasColumn('eventi', 'descrizione_evento')) {
Schema::table('eventi', function (Blueprint $table) {
$table->text('descrizione_evento')->nullable()->after('nome_evento');
});
}
if (!Schema::hasColumn('eventi', 'is_incontro_gruppo')) {
Schema::table('eventi', function (Blueprint $table) {
$table->boolean('is_incontro_gruppo')->default(false)->after('uid_esterno');
});
}
}
public function down(): void
{
}
};
+1
View File
@@ -17,6 +17,7 @@ class DatabaseSeeder extends Seeder
ComuniSeeder::class,
RoleSeeder::class,
RolePresetSeeder::class,
DockerBaseDataSeeder::class,
]);
$admin = User::create([
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class DockerBaseDataSeeder extends Seeder
{
public function run(): void
{
$tipologieDoc = ['documento', 'avatar', 'galleria', 'statuto', 'programma', 'locandina', 'altro'];
foreach ($tipologieDoc as $ord => $nome) {
DB::table('tipologie_documenti')->updateOrInsert(
['nome' => $nome],
['descrizione' => ucfirst($nome), 'ordine' => $ord + 1, 'attiva' => true]
);
}
$tipologieEventi = [
['nome' => 'catechesi', 'descrizione' => 'Catechesi'],
['nome' => 'liturgia', 'descrizione' => 'Liturgia'],
['nome' => 'animazione', 'descrizione' => 'Animazione'],
['nome' => 'formazione', 'descrizione' => 'Formazione'],
['nome' => 'incontro', 'descrizione' => 'Incontro'],
['nome' => 'riunione', 'descrizione' => 'Riunione'],
['nome' => 'festa', 'descrizione' => 'Festa'],
['nome' => 'altro', 'descrizione' => 'Altro'],
];
foreach ($tipologieEventi as $ord => $t) {
DB::table('tipologie_eventi')->updateOrInsert(
['nome' => $t['nome']],
['descrizione' => $t['descrizione'], 'ordine' => $ord + 1, 'attiva' => true]
);
}
$ruoli = ['presidente', 'vicepresidente', 'segretario', 'tesoriere', 'consigliere', 'volontario', 'socio', 'altro'];
foreach ($ruoli as $ord => $nome) {
DB::table('ruoli')->updateOrInsert(
['nome' => $nome],
['descrizione' => ucfirst($nome), 'ordine' => $ord + 1, 'attiva' => true]
);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
services:
glastree:
build: .
ports:
- "8080:80"
volumes:
- glastree_storage:/var/www/html/storage
depends_on:
mysql:
condition: service_healthy
environment:
- APP_ENV=production
- APP_DEBUG=false
- APP_URL=http://localhost:8080
- DB_CONNECTION=mysql
- DB_HOST=mysql
- DB_PORT=3306
- DB_DATABASE=glastree
- DB_USERNAME=glastree
- DB_PASSWORD=secret
mysql:
image: mysql:8.0
volumes:
- mysql_data:/var/lib/mysql
environment:
- MYSQL_DATABASE=glastree
- MYSQL_USER=glastree
- MYSQL_PASSWORD=secret
- MYSQL_ROOT_PASSWORD=root_secret
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 3s
retries: 10
volumes:
glastree_storage:
mysql_data:
+17
View File
@@ -0,0 +1,17 @@
services:
glastree:
build: .
ports:
- "8080:80"
volumes:
- glastree_storage:/var/www/html/storage
- glastree_db:/var/www/html/database
environment:
- APP_ENV=production
- APP_DEBUG=false
- APP_URL=http://localhost:8080
- DB_CONNECTION=sqlite
volumes:
glastree_storage:
glastree_db:
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
set -e
if [ ! -f .env ]; then
cp .env.example .env
if [ "$DB_CONNECTION" = "mysql" ]; then
sed -i "s/DB_CONNECTION=mysql/DB_CONNECTION=mysql/" .env
sed -i "s/DB_HOST=.*/DB_HOST=${DB_HOST:-mysql}/" .env
sed -i "s/DB_PORT=.*/DB_PORT=${DB_PORT:-3306}/" .env
sed -i "s/DB_DATABASE=.*/DB_DATABASE=${DB_DATABASE:-glastree}/" .env
sed -i "s/DB_USERNAME=.*/DB_USERNAME=${DB_USERNAME:-glastree}/" .env
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=${DB_PASSWORD:-secret}/" .env
echo "Waiting for MySQL..."
until php -r "new PDO('mysql:host=${DB_HOST:-mysql};dbname=${DB_DATABASE:-glastree}', '${DB_USERNAME:-glastree}', '${DB_PASSWORD:-secret}');" 2>/dev/null; do
sleep 2
done
echo "MySQL ready."
else
sed -i 's/DB_CONNECTION=mysql/DB_CONNECTION=sqlite/' .env
sed -i 's/DB_HOST=.*//' .env
sed -i 's/DB_PORT=.*//' .env
sed -i 's/DB_DATABASE=.*/DB_DATABASE=\/var\/www\/html\/database\/database.sqlite/' .env
sed -i 's/DB_USERNAME=.*//' .env
sed -i 's/DB_PASSWORD=.*//' .env
touch database/database.sqlite
chmod 664 database/database.sqlite
fi
php artisan key:generate --force
fi
if [ ! -f .db_initialized ]; then
php artisan package:discover --ansi 2>/dev/null || true
php artisan migrate --force
php artisan db:seed --force
touch .db_initialized
fi
php artisan storage:link --force 2>/dev/null || true
php artisan optimize:clear 2>/dev/null || true
exec "$@"
Binary file not shown.
+848
View File
@@ -0,0 +1,848 @@
#!/usr/bin/env php
<?php
/**
* Glastree — Installer interattivo per amministratori (PHP CLI)
*
* Uso:
* php install.php
*
* Richiede: PHP 8.3+, Composer, MySQL o SQLite
* Opzionale: npm (per asset frontend)
*
* Modalità:
* 1) Fresh Install su Apache (LAMP)
* 2) Fresh Install via Docker
* 3) Restore da Backup (Apache)
*/
// ── Safety ──────────────────────────────────────────
declare(strict_types=1);
// ── Constants ────────────────────────────────────────
const REQUIREMENTS = ['pdo_mysql', 'mbstring', 'xml', 'curl', 'zip', 'gd', 'fileinfo'];
const MIN_PHP = '8.3.0';
// ── Utilities ────────────────────────────────────────
function println(string $msg = ''): void { echo $msg . PHP_EOL; }
function color(string $text, string $code): string {
$map = [
'red' => '0;31',
'green' => '0;32',
'yellow' => '1;33',
'cyan' => '0;36',
'white' => '1;37',
];
$c = $map[$code] ?? '0';
return "\033[{$c}m{$text}\033[0m";
}
function info(string $msg): void { println(color('[INFO]', 'cyan') . ' ' . $msg); }
function ok(string $msg): void { println(color('[OK]', 'green') . ' ' . $msg); }
function warn(string $msg): void { println(color('[WARN]', 'yellow') . ' ' . $msg); }
function error(string $msg): void { println(color('[ERR]', 'red') . ' ' . $msg); }
function fail(string $msg): never { error($msg); exit(1); }
function ask(string $prompt, ?string $default = null): string {
$defaultStr = $default !== null ? " [{$default}]" : '';
echo color('?', 'cyan') . " {$prompt}{$defaultStr}: ";
$val = trim(fgets(STDIN) ?: '');
return $val !== '' ? $val : ($default ?? '');
}
function askRequired(string $prompt): string {
while (true) {
$val = ask($prompt);
if ($val !== '') break;
warn('Valore obbligatorio.');
}
return $val;
}
function confirm(string $prompt): bool {
$resp = strtolower(ask($prompt . ' [S/n]', 'S'));
return $resp === 's' || $resp === '';
}
function menu(string $prompt, array $options): string {
foreach ($options as $i => $opt) {
println(' ' . ($i + 1) . ') ' . $opt);
}
while (true) {
$val = ask($prompt, '1');
if (isset($options[(int)$val - 1])) return $options[(int)$val - 1];
warn('Scelta non valida.');
}
}
function checkCmd(string $cmd): bool {
$found = !(trim(shell_exec("command -v $cmd 2>/dev/null") ?: '') === '');
if (!$found) warn("Comando '$cmd' non trovato.");
return $found;
}
function run(string $cmd, ?string $label = null): bool {
if ($label) info($label);
println(" \$ {$cmd}");
passthru($cmd, $exitCode);
if ($exitCode !== 0) warn("Comando terminato con codice {$exitCode}");
return $exitCode === 0;
}
function runCapture(string $cmd): string {
return trim(shell_exec($cmd) ?: '');
}
/**
* Crea l'utente amministratore via PDO prepared statement.
* Nessun escaping PHP necessario (tutto via prepared statement).
*/
function createAdminUser(
string $dbHost, string $dbPort, string $dbName,
string $dbUser, string $dbPass,
string $adminName, string $adminEmail, string $adminPass
): bool {
try {
$pdo = new PDO(
"mysql:host={$dbHost};port={$dbPort};dbname={$dbName}",
$dbUser, $dbPass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$hash = password_hash($adminPass, PASSWORD_BCRYPT);
$permissions = json_encode([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
]);
// Elimina eventuale utente con stessa email (seeder)
$stmt = $pdo->prepare('DELETE FROM users WHERE email = ?');
$stmt->execute([$adminEmail]);
// Inserisce admin con is_admin=1, status='active', permissions completi
$stmt = $pdo->prepare(
"INSERT INTO users (name, email, password, is_admin, status, permissions, role_preset_id, created_at, updated_at)
VALUES (?, ?, ?, 1, 'active', ?, 1, NOW(), NOW())"
);
$stmt->execute([$adminName, $adminEmail, $hash, $permissions]);
$userId = $pdo->lastInsertId();
// Assegna ruolo Spatie 'admin' (role_id = 1)
$stmt = $pdo->prepare(
"INSERT INTO model_has_roles (role_id, model_type, model_id) VALUES (1, ?, ?)"
);
$stmt->execute(['App\\Models\\User', $userId]);
return true;
} catch (\PDOException $e) {
error("Errore creazione admin: " . $e->getMessage());
return false;
}
}
// ── Banner ───────────────────────────────────────────
println(color(' ____ _ _ _', 'white'));
println(color(' / ___| | __ _ ___| |_ ___ _ __ ___ | |_ ___', 'white'));
println(color('| | _| |/ _` / __| __/ _ \'__/ _ \ | __/ _ \\', 'white'));
println(color('| |_| | | (_| \__ \ || __/ | | __/ | || __/', 'white'));
println(color(' \____|_|\__,_|___/\__\___|_| \___| \__\___|', 'white'));
println(color(' Installer interattivo per sysadmin', 'cyan'));
println();
// ── Preflight ────────────────────────────────────────
$scriptDir = dirname(__FILE__);
chdir($scriptDir);
if (!file_exists('.env.example')) {
fail("File .env.example non trovato in {$scriptDir}. Sei nella cartella dell'applicazione?");
}
// ── Select mode ──────────────────────────────────────
println('Seleziona la modalità di installazione:');
$mode = menu('Scegli', [
'Fresh Install su Apache (LAMP tradizionale)',
'Fresh Install via Docker',
'Restore da Backup (Apache)',
]);
// ── Common params ────────────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PARAMETRI GENERALI', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$appName = askRequired('Nome del sito/app (es. Glastree MyChurch)');
$adminName = askRequired('Nome del primo utente amministratore');
$adminEmail = askRequired('Email dell\'amministratore');
$adminPass = askRequired('Password per l\'amministratore');
$appUrl = askRequired('URL pubblico del sito (es. https://glastree.esempio.it)');
// ── DB params (Apache / Restore) ─────────────────────
$dbType = 'mysql';
$dbHost = '127.0.0.1';
$dbPort = '3306';
$dbName = 'glastree';
$dbUser = 'glastree';
$dbPass = '';
if (in_array($mode, ['Fresh Install su Apache (LAMP tradizionale)', 'Restore da Backup (Apache)'])) {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' DATABASE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$dbType = menu('Tipo database', ['mysql', 'sqlite']);
if ($dbType === 'mysql') {
$dbHost = askRequired('Host database');
$dbPort = ask('Porta database', '3306');
$dbName = ask('Nome database (verrà creato se non esiste)', 'glastree');
$dbUser = askRequired('Utente database');
$dbPass = askRequired('Password database');
}
}
// ── Web server user detection ─────────────────────────
$wwwUser = 'www-data';
$sudo = '';
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
$detected = trim(shell_exec('ps aux | grep -E "apache|httpd" | grep -v grep | head -1 | awk \'{print $1}\'') ?: '');
if ($detected !== '' && $detected !== 'root') {
$wwwUser = $detected;
}
if (function_exists('posix_getuid') && posix_getuid() === 0) {
$sudo = "sudo -u {$wwwUser} ";
}
}
// ══════════════════════════════════════════════════════
// MODE 1: FRESH INSTALL APACHE
// ══════════════════════════════════════════════════════
if ($mode === 'Fresh Install su Apache (LAMP tradizionale)') {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PRE-FLIGHT CHECKS', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
// PHP version
$phpVer = PHP_VERSION;
info("PHP versione: {$phpVer}");
if (version_compare($phpVer, MIN_PHP, '<')) {
fail("PHP >= " . MIN_PHP . " richiesto (trovato {$phpVer})");
}
ok("PHP {$phpVer}");
// Extensions
foreach (REQUIREMENTS as $ext) {
if (!extension_loaded($ext)) {
warn("Estensione PHP '{$ext}' non trovata (alcune funzionalità potrebbero non funzionare)");
} else {
ok("Estensione PHP '{$ext}'");
}
}
// Composer
checkCmd('composer');
checkCmd('node');
checkCmd('npm');
// ── Step 1: Environment ─────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 1/7 — CONFIGURAZIONE AMBIENTE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if (!file_exists('.env')) {
copy('.env.example', '.env');
ok('.env creato da .env.example');
} else {
info('.env già esistente, lo aggiorno');
}
// APP_KEY
runCapture("{$sudo}php artisan key:generate --force");
ok('APP_KEY generata');
// Config
$dotenv = file_get_contents('.env');
$replacements = [
'/^APP_NAME=.*/m' => 'APP_NAME="' . addslashes($appName) . '"',
'/^APP_URL=.*/m' => "APP_URL={$appUrl}",
'/^APP_ENV=.*/m' => 'APP_ENV=production',
'/^APP_DEBUG=.*/m' => 'APP_DEBUG=false',
];
if ($dbType === 'mysql') {
$replacements['/^DB_CONNECTION=.*/m'] = 'DB_CONNECTION=mysql';
$replacements['/^DB_HOST=.*/m'] = "DB_HOST={$dbHost}";
$replacements['/^DB_PORT=.*/m'] = "DB_PORT={$dbPort}";
$replacements['/^DB_DATABASE=.*/m'] = "DB_DATABASE={$dbName}";
$replacements['/^DB_USERNAME=.*/m'] = "DB_USERNAME={$dbUser}";
$replacements['/^DB_PASSWORD=.*/m'] = "DB_PASSWORD={$dbPass}";
} else {
$replacements['/^DB_CONNECTION=.*/m'] = 'DB_CONNECTION=sqlite';
$replacements['/^DB_HOST=.*/m'] = '# DB_HOST=';
$replacements['/^DB_PORT=.*/m'] = '# DB_PORT=';
$replacements['/^DB_DATABASE=.*/m'] = 'DB_DATABASE=' . $scriptDir . '/database/database.sqlite';
$replacements['/^DB_USERNAME=.*/m'] = '# DB_USERNAME=';
$replacements['/^DB_PASSWORD=.*/m'] = '# DB_PASSWORD=';
}
foreach ($replacements as $pattern => $replacement) {
$dotenv = preg_replace($pattern, $replacement, $dotenv);
}
file_put_contents('.env', $dotenv);
ok('.env configurato');
// ── Step 2: Database ────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 2/7 — DATABASE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if ($dbType === 'mysql') {
info("Connessione a MySQL {$dbHost}:{$dbPort}, database '{$dbName}'...");
try {
new PDO(
"mysql:host={$dbHost};port={$dbPort};dbname={$dbName}",
$dbUser, $dbPass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
);
ok("Accesso a '{$dbName}' come '{$dbUser}' riuscito");
} catch (\PDOException $e) {
// Diagnostica l'errore
$errMsg = $e->getMessage();
$diagnosi = match (true) {
(bool)preg_match('/1049|Unknown database/', $errMsg) => "Database '{$dbName}' non esiste",
(bool)preg_match('/1045|Access denied for user/', $errMsg) => "Utente '{$dbUser}' o password non validi",
(bool)preg_match('/1044/', $errMsg) => "Utente '{$dbUser}' non ha accesso al database '{$dbName}'",
default => $errMsg,
};
warn($diagnosi);
info("Connessione come root per creare/riparare utente e database...");
$rootPdo = null;
try {
$rootPdo = new PDO(
"mysql:host={$dbHost};port={$dbPort}",
'root', '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
);
} catch (\PDOException) {
$rootPass = askRequired('Password utente root MySQL (lascia vuoto se senza password)');
try {
$rootPdo = new PDO(
"mysql:host={$dbHost};port={$dbPort}",
'root', $rootPass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
);
} catch (\PDOException $r) {
fail("Connessione come root fallita: " . $r->getMessage());
}
}
$safeUser = str_replace(["'", '"', '`', "\0", '\\'], '', $dbUser);
$safePass = str_replace(["'", '"', '`', "\0", '\\'], '', $dbPass);
$safeName = str_replace(["'", '"', '`', "\0", '\\'], '', $dbName);
$rootPdo->exec("CREATE DATABASE IF NOT EXISTS `{$safeName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
foreach (['localhost', '%'] as $host) {
$rootPdo->exec("CREATE USER IF NOT EXISTS '{$safeUser}'@'{$host}' IDENTIFIED BY '{$safePass}'");
$rootPdo->exec("GRANT ALL PRIVILEGES ON `{$safeName}`.* TO '{$safeUser}'@'{$host}'");
}
$rootPdo->exec("FLUSH PRIVILEGES");
ok("Database '{$dbName}' e utente '{$dbUser}' configurati con permessi completi");
}
} else {
$sqlitePath = $scriptDir . '/database/database.sqlite';
if (!file_exists($sqlitePath)) {
touch($sqlitePath);
chmod($sqlitePath, 0664);
}
ok('SQLite pronto: ' . $sqlitePath);
}
// ── Step 3: Composer ────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 3/7 — INSTALLAZIONE DIPENDENZE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
run("COMPOSER_ROOT_VERSION=dev-main composer install --no-dev --optimize-autoloader", 'Installazione dipendenze Composer...');
ok('Dipendenze PHP installate');
// ── Step 4: Package discovery ────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 4/7 — REGISTRAZIONE PACCHETTI', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
runCapture("{$sudo}php artisan package:discover --ansi 2>/dev/null");
ok('Pacchetti registrati');
// ── Step 5: Schema + Seed ────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 5/7 — SCHEMA DATABASE E DATI BASE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$sqlFile = $scriptDir . '/database/install.sql';
if (!file_exists($sqlFile)) {
fail("File database/install.sql non trovato in {$scriptDir}");
}
if ($dbType === 'mysql') {
$importCmd = "mysql -h {$dbHost} -P {$dbPort} -u {$dbUser} -p{$dbPass} {$dbName} < {$sqlFile}";
if (!run($importCmd, 'Importazione struttura database e dati base...')) {
fail("Importazione SQL fallita. Verifica il file database/install.sql e le credenziali.");
}
} else {
// SQLite fallback: usa le migration (install.sql è solo per MySQL)
if (!run("{$sudo}php artisan migrate --seed --force", 'Esecuzione migration e seed...')) {
fail('Migration fallita. Verifica la configurazione SQLite.');
}
}
ok('Database popolato con struttura e dati base');
runCapture("{$sudo}php artisan storage:link --force 2>/dev/null");
// ── Step 6: Admin user (via PDO — no tinker) ─────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 6/7 — CREAZIONE AMMINISTRATORE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if ($dbType === 'mysql') {
if (createAdminUser($dbHost, $dbPort, $dbName, $dbUser, $dbPass, $adminName, $adminEmail, $adminPass)) {
ok("Amministratore {$adminEmail} creato con permessi completi");
} else {
fail("Creazione admin fallita. Verifica i dati inseriti.");
}
} else {
// SQLite: usa seeder (l'admin verrà creato da DatabaseSeeder)
// Poi aggiorna la password con quella scelta dall'utente
runCapture("{$sudo}php artisan tinker --execute=" . escapeshellarg(
"\$u = \App\Models\User::first(); " .
"if (\$u) { " .
"\$u->name = " . var_export($adminName, true) . "; " .
"\$u->email = " . var_export($adminEmail, true) . "; " .
"\$u->password = bcrypt(" . var_export($adminPass, true) . "); " .
"\$u->is_admin = true; " .
"\$u->status = 'active'; " .
"\$u->permissions = " . var_export([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
], true) . "; " .
"\$u->role_preset_id = 1; " .
"\$u->save(); " .
"echo 'OK'; " .
"}"
) . " 2>/dev/null");
ok("Amministratore {$adminEmail} configurato");
}
// Clear all caches after schema + admin creation
runCapture("{$sudo}php artisan optimize:clear 2>/dev/null");
ok('Cache ottimizzate');
// ── Step 7: Asset ────────────────────────────────
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' 7/7 — ASSET FRONTEND', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$hasNpm = checkCmd('npm');
if ($hasNpm && confirm('Compilare gli asset frontend con npm?')) {
run("{$sudo}npm install", 'Installazione dipendenze npm...');
run("{$sudo}npm run build", 'Compilazione asset...');
ok('Asset compilati');
} else {
if (!$hasNpm) warn('npm non disponibile — asset non compilati (AdminLTE via CDN)');
else info('Asset non compilati (AdminLTE via CDN)');
}
// ── Permissions ──────────────────────────────────
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
info('Impostazione permessi...');
runCapture("chown -R {$wwwUser}:{$wwwUser} vendor storage bootstrap/cache 2>/dev/null");
chmod("{$scriptDir}/storage", 0775);
chmod("{$scriptDir}/bootstrap/cache", 0775);
ok("Permessi impostati (utente: {$wwwUser})");
}
// ── Summary ──────────────────────────────────────
println();
println(color('══════════════════════════════════════════', 'green'));
println(color(' INSTALLAZIONE COMPLETATA!', 'green'));
println(color('══════════════════════════════════════════', 'green'));
println();
println(" URL: {$appUrl}");
println(" Admin: {$adminEmail}");
println(" Cartella: {$scriptDir}");
println();
warn('Configura Apache con il VirtualHost (vedi Guida → Installazione, Passo 4).');
if ($dbType === 'mysql') {
warn('Se hai appena creato il database, assicurati che l\'utente abbia privilegi completi.');
}
}
// ══════════════════════════════════════════════════════
// MODE 2: DOCKER
// ══════════════════════════════════════════════════════
if ($mode === 'Fresh Install via Docker') {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PRE-FLIGHT CHECKS (Docker)', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
checkCmd('docker');
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' CONFIGURAZIONE DOCKER', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
$dockerDb = menu('Tipo database per Docker', ['sqlite', 'mysql']);
$dockerPort = ask('Porta host (es. 8080)', '8080');
$composeFile = $dockerDb === 'mysql' ? 'docker-compose.mysql.yml' : 'docker-compose.yml';
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' INSTALLAZIONE DOCKER', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if (!run("docker compose -f {$composeFile} build", 'Build immagine Docker...')) {
fail('Build fallita');
}
// Override compose con variabili d'ambiente
$override = [
'services:',
' glastree:',
' environment:',
" - APP_NAME={$appName}",
" - APP_URL={$appUrl}",
' - APP_ENV=production',
' - APP_DEBUG=false',
];
file_put_contents('docker-compose.override.yml', implode(PHP_EOL, $override) . PHP_EOL);
if (!run(
"docker compose -f {$composeFile} -f docker-compose.override.yml up -d",
'Avvio container...'
)) {
@unlink('docker-compose.override.yml');
fail('Avvio container fallito');
}
// Attendi container pronto
info('Attendo che il container sia pronto...');
$containerId = runCapture("docker compose -f {$composeFile} ps -q glastree 2>/dev/null");
$adminCreated = false;
if ($containerId !== '') {
for ($i = 0; $i < 30; $i++) {
$status = runCapture("docker inspect -f '{{.State.Status}}' {$containerId} 2>/dev/null");
if ($status === 'running') {
$http = runCapture("docker exec {$containerId} sh -c 'wget -q -O- http://localhost/ 2>/dev/null && echo OK'");
if (str_contains($http, 'OK')) {
info('Container pronto');
break;
}
}
sleep(2);
}
// Import schema SQL nel container
$sqlFile = $scriptDir . '/database/install.sql';
if (file_exists($sqlFile) && $dockerDb === 'mysql') {
info('Importazione schema nel container...');
runCapture("docker cp {$sqlFile} {$containerId}:/tmp/install.sql 2>/dev/null");
$mysqlHost = 'mysql';
$mysqlUser = 'glastree';
$mysqlPass = 'secret';
$mysqlDb = 'glastree';
$importResult = runCapture(
"docker exec {$containerId} sh -c 'mysql -h {$mysqlHost} -u {$mysqlUser} -p{$mysqlPass} {$mysqlDb} < /tmp/install.sql' 2>&1"
);
if ($importResult !== '') {
warn("Import SQL: {$importResult}");
} else {
ok('Schema database importato');
}
} elseif ($dockerDb === 'sqlite') {
run("docker exec {$containerId} php artisan migrate --seed --force", 'Migration e seed...');
}
// Crea admin
if ($dockerDb === 'mysql') {
// Script PHP temporaneo con var_export (safe)
$adminScriptPath = sys_get_temp_dir() . '/glastree-create-admin-' . uniqid() . '.php';
$hash = password_hash($adminPass, PASSWORD_BCRYPT);
$perm = json_encode([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
]);
$nameExp = var_export($adminName, true);
$emailExp = var_export($adminEmail, true);
$hashExp = var_export($hash, true);
$userExp = var_export($mysqlUser, true);
$passExp = var_export($mysqlPass, true);
$dbExp = var_export($mysqlDb, true);
$hostExp = var_export($mysqlHost, true);
$adminPhp = <<<PHP
<?php
\$pdo = new PDO("mysql:host=" . {$hostExp} . ";dbname=" . {$dbExp}, {$userExp}, {$passExp}, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
\$pdo->prepare("DELETE FROM users WHERE email = ?")->execute([{$emailExp}]);
\$pdo->prepare("INSERT INTO users (name, email, password, is_admin, status, permissions, role_preset_id, created_at, updated_at) VALUES (?, ?, ?, 1, 'active', ?, 1, NOW(), NOW())")->execute([{$nameExp}, {$emailExp}, {$hashExp}, '{$perm}']);
\$uid = \$pdo->lastInsertId();
\$pdo->prepare("INSERT INTO model_has_roles (role_id, model_type, model_id) VALUES (1, ?, ?)")->execute(['App\\\\Models\\\\User', \$uid]);
echo "OK:" . \$uid;
PHP;
file_put_contents($adminScriptPath, $adminPhp);
runCapture("docker cp {$adminScriptPath} {$containerId}:/tmp/create_admin.php 2>/dev/null");
$adminResult = runCapture("docker exec {$containerId} php /tmp/create_admin.php 2>/dev/null");
@unlink($adminScriptPath);
} else {
// SQLite: usa tinker via docker exec
$adminResult = runCapture("docker exec {$containerId} php artisan tinker --execute=" . escapeshellarg(
"\$u = \App\Models\User::first(); " .
"if (\$u) { " .
"\$u->name = " . var_export($adminName, true) . "; " .
"\$u->email = " . var_export($adminEmail, true) . "; " .
"\$u->password = bcrypt(" . var_export($adminPass, true) . "); " .
"\$u->is_admin = true; " .
"\$u->status = 'active'; " .
"\$u->permissions = " . var_export([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
], true) . "; " .
"\$u->role_preset_id = 1; " .
"\$u->save(); " .
"echo 'OK'; " .
"}"
) . " 2>/dev/null");
}
if (str_contains($adminResult ?? '', 'OK')) {
ok("Amministratore {$adminEmail} creato nel container");
$adminCreated = true;
} else {
warn("Creazione admin fallita — output: " . (($adminResult ?? '') ?: 'nessun output'));
}
}
@unlink('docker-compose.override.yml');
println();
println(color('══════════════════════════════════════════', 'green'));
println(color(' INSTALLAZIONE DOCKER COMPLETATA!', 'green'));
println(color('══════════════════════════════════════════', 'green'));
println();
println(" URL: http://localhost:{$dockerPort}");
println(" Admin: {$adminEmail}");
println(" Container: {$containerId}");
println();
warn('Per URL pubblici, imposta APP_URL nelle environment del container.');
warn('Dati persistenti: volumi Docker (glastree_storage, glastree_db o mysql_data).');
}
// ══════════════════════════════════════════════════════
// MODE 3: RESTORE DA BACKUP
// ══════════════════════════════════════════════════════
if ($mode === 'Restore da Backup (Apache)') {
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' PRE-FLIGHT CHECKS (Restore)', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
checkCmd('php');
checkCmd('composer');
checkCmd('unzip');
if ($dbType === 'mysql') {
checkCmd('mysql');
}
$backupZip = askRequired('Percorso del file ZIP di backup');
if (!file_exists($backupZip)) {
fail("File non trovato: {$backupZip}");
}
println();
println(color('══════════════════════════════════════════', 'yellow'));
println(color(' RESTORE DA BACKUP', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
// Extract
$tmpDir = sys_get_temp_dir() . '/glastree-restore-' . uniqid();
mkdir($tmpDir, 0700, true);
info("Estrazione backup in {$tmpDir}...");
if (!run("unzip -qo {$backupZip} -d {$tmpDir}", null)) {
// pulisci tmp dir prima di fallire
runCapture("rm -rf {$tmpDir}");
fail('Estrazione backup fallita');
}
// Environment
if (!file_exists('.env')) {
copy('.env.example', '.env');
}
// Restore .env from backup preserving DB credentials
if (file_exists("{$tmpDir}/.env")) {
$currentEnv = file_get_contents('.env');
$backupEnv = file_get_contents("{$tmpDir}/.env");
// Save current DB_* values
$currentDb = [];
foreach (['DB_CONNECTION', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD'] as $key) {
if (preg_match("/^{$key}=(.*)$/m", $currentEnv, $m)) {
$currentDb[$key] = $m[1];
}
}
// Use backup .env as base
file_put_contents('.env', $backupEnv);
// Restore current DB credentials
foreach ($currentDb as $key => $val) {
$dotenv = file_get_contents('.env');
$dotenv = preg_replace("/^{$key}=.*/m", "{$key}={$val}", $dotenv);
file_put_contents('.env', $dotenv);
}
ok('.env ripristinato dal backup (credenziali DB preservate)');
}
// Update APP_* config
$dotenv = file_get_contents('.env');
$dotenv = preg_replace('/^APP_NAME=.*/m', 'APP_NAME="' . addslashes($appName) . '"', $dotenv);
$dotenv = preg_replace('/^APP_URL=.*/m', "APP_URL={$appUrl}", $dotenv);
$dotenv = preg_replace('/^APP_ENV=.*/m', 'APP_ENV=production', $dotenv);
$dotenv = preg_replace('/^APP_DEBUG=.*/m', 'APP_DEBUG=false', $dotenv);
file_put_contents('.env', $dotenv);
// Generate APP_KEY
runCapture("{$sudo}php artisan key:generate --force");
ok('APP_KEY rigenerata');
// Import database
if ($dbType === 'mysql') {
$sqlFile = "{$tmpDir}/database.sql";
if (file_exists($sqlFile)) {
info("Importazione database MySQL: {$sqlFile}");
$cmd = "mysql -h {$dbHost} -P {$dbPort} -u {$dbUser} -p{$dbPass} {$dbName} < {$sqlFile}";
if (run($cmd, null)) {
ok('Database importato');
} else {
warn('Import database fallito — importa manualmente');
}
} else {
warn('database.sql non trovato nel backup');
}
} else {
warn('Restore automatico supportato solo con MySQL. Per SQLite ripristina manualmente i file.');
}
// Restore storage
$storageBackup = "{$tmpDir}/storage";
if (is_dir($storageBackup)) {
info('Ripristino file storage...');
runCapture("cp -r {$storageBackup}/* {$scriptDir}/storage/ 2>/dev/null");
ok('Storage ripristinato');
}
// Composer
run("COMPOSER_ROOT_VERSION=dev-main composer install --no-dev --optimize-autoloader", 'Installazione dipendenze Composer...');
runCapture("{$sudo}php artisan package:discover --ansi 2>/dev/null");
// Storage link + cache
runCapture("{$sudo}php artisan storage:link --force 2>/dev/null");
runCapture("{$sudo}php artisan optimize:clear");
// Permissions
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
runCapture("chown -R {$wwwUser}:{$wwwUser} vendor storage bootstrap/cache 2>/dev/null");
chmod("{$scriptDir}/storage", 0775);
chmod("{$scriptDir}/bootstrap/cache", 0775);
ok("Permessi impostati (utente: {$wwwUser})");
}
// Create admin if not exists (via PDO)
if ($dbType === 'mysql') {
$exists = false;
try {
$pdo = new PDO("mysql:host={$dbHost};port={$dbPort};dbname={$dbName}", $dbUser, $dbPass);
$stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE email = ?');
$stmt->execute([$adminEmail]);
$exists = (int) $stmt->fetchColumn() > 0;
} catch (\PDOException) {
// Ignora — tenteremo la creazione
}
if ($exists) {
ok("Amministratore {$adminEmail} già esistente");
} elseif (createAdminUser($dbHost, $dbPort, $dbName, $dbUser, $dbPass, $adminName, $adminEmail, $adminPass)) {
ok("Amministratore {$adminEmail} creato");
} else {
warn("Creazione admin fallita — crealo manualmente dal pannello admin");
}
} else {
// SQLite fallback: usa tinker
$result = runCapture("{$sudo}php artisan tinker --execute=" . escapeshellarg(
"\$e = \App\Models\User::where('email', " . var_export($adminEmail, true) . ")->exists(); " .
"if (!\$e) { " .
"\$u = new \App\Models\User; " .
"\$u->name = " . var_export($adminName, true) . "; " .
"\$u->email = " . var_export($adminEmail, true) . "; " .
"\$u->password = bcrypt(" . var_export($adminPass, true) . "); " .
"\$u->is_admin = true; " .
"\$u->status = 'active'; " .
"\$u->permissions = " . var_export([
'individui' => 2, 'gruppi' => 2, 'eventi' => 2,
'documenti' => 2, 'mailing' => 2, 'viste' => 2,
'report' => 2, 'settings' => 2,
], true) . "; " .
"\$u->role_preset_id = 1; " .
"\$u->save(); " .
"echo 'OK'; " .
"} else { echo 'EXISTS'; }"
) . " 2>/dev/null");
if (str_contains($result, 'OK')) {
ok("Amministratore {$adminEmail} creato");
} elseif (str_contains($result, 'EXISTS')) {
ok("Amministratore {$adminEmail} già esistente");
} else {
warn("Creazione admin fallita (output: " . ($result ?: 'vuoto') . ")");
}
}
// Cleanup
runCapture("rm -rf {$tmpDir}");
println();
println(color('══════════════════════════════════════════', 'green'));
println(color(' RESTORE COMPLETATO!', 'green'));
println(color('══════════════════════════════════════════', 'green'));
println();
println(" URL: {$appUrl}");
println(" Admin: {$adminEmail}");
println(" Cartella: {$scriptDir}");
println();
warn('Verifica che APP_KEY non abbia invalidato dati criptati (token OAuth, password IMAP).');
warn('Se necessario, riconfigura SMTP e Google Drive dalle Impostazioni.');
}
println();
@@ -14,9 +14,14 @@
<div class="card">
<div class="card-header">
<h3 class="card-title">Attività di Sistema</h3>
<div class="card-tools">
<button type="button" class="btn btn-danger btn-sm" onclick="deleteLogs()">
<i class="fas fa-trash mr-1"></i> Cancella Log
</button>
</div>
</div>
<div class="card-body">
<form method="GET" class="mb-3">
<form method="GET" class="mb-3" id="filter-form">
<div class="row">
<div class="col-md-2">
<select name="user_id" class="form-control">
@@ -94,4 +99,59 @@
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
function deleteLogs() {
var filterForm = document.getElementById('filter-form');
var params = new URLSearchParams(new FormData(filterForm));
var filterDescription = [];
var userFilter = params.get('user_id');
var moduleFilter = params.get('module');
var actionFilter = params.get('action');
var fromFilter = params.get('from');
var toFilter = params.get('to');
if (userFilter) filterDescription.push('utente=' + userFilter);
if (moduleFilter) filterDescription.push('modulo=' + moduleFilter);
if (actionFilter) filterDescription.push('azione=' + actionFilter);
if (fromFilter) filterDescription.push('dal=' + fromFilter);
if (toFilter) filterDescription.push('al=' + toFilter);
var message = filterDescription.length > 0
? 'Eliminare i log filtrati? (' + filterDescription.join(', ') + ')'
: 'Eliminare TUTTI i log?';
if (!confirm(message)) return;
var form = document.createElement('form');
form.method = 'POST';
form.action = '/admin/activity-logs';
form.style.display = 'none';
var csrf = document.createElement('input');
csrf.name = '_token';
csrf.value = '{{ csrf_token() }}';
form.appendChild(csrf);
var method = document.createElement('input');
method.name = '_method';
method.value = 'DELETE';
form.appendChild(method);
filterForm.querySelectorAll('select, input').forEach(function(el) {
if (el.name && el.value) {
var input = document.createElement('input');
input.name = el.name;
input.value = el.value;
form.appendChild(input);
}
});
document.body.appendChild(form);
form.submit();
}
</script>
@endsection
@@ -0,0 +1,247 @@
@extends('admin.layout')
@php $appName = \App\Models\AppSetting::getAppName() ?? 'Glastree'; @endphp
@section('title', 'Backup - ' . $appName)
@section('page_title', 'Backup e Migrazione')
@section('breadcrumbs')
<li class="breadcrumb-item"><a href="/">Home</a></li>
<li class="breadcrumb-item"><a href="/admin/utenti">Admin</a></li>
<li class="breadcrumb-item active">Backup</li>
@endsection
@section('content')
<div class="row">
<div class="col-md-8">
{{-- Lista backup --}}
<div class="card card-outline card-primary">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-hdd mr-2"></i>Backup Disponibili</h3>
<div class="card-tools">
<form method="POST" action="{{ route('admin.backup.run') }}" style="display:inline">
@csrf
<button type="submit" class="btn btn-primary btn-sm">
<i class="fas fa-play mr-1"></i> Esegui Backup Ora
</button>
</form>
</div>
</div>
<div class="card-body p-0">
@if(count($backups) > 0)
<div class="table-responsive">
<table class="table table-hover table-striped mb-0">
<thead>
<tr>
<th>File</th>
<th>Data</th>
<th>Dimensione</th>
<th>Contenuto</th>
<th class="text-center">Azioni</th>
</tr>
</thead>
<tbody>
@foreach($backups as $backup)
<tr>
<td><code>{{ $backup['filename'] }}</code></td>
<td>{{ date('d/m/Y H:i', $backup['last_modified']) }}</td>
<td>{{ $backup['size_formatted'] }}</td>
<td>
@if($backup['manifest'])
<span class="badge badge-info" title="PHP {{ $backup['manifest']['php_version'] ?? '?' }}, Laravel {{ $backup['manifest']['laravel_version'] ?? '?' }}">
<i class="fas fa-database mr-1"></i>DB
</span>
@if(isset($backup['manifest']['db_name']))
<small class="text-muted">{{ $backup['manifest']['db_name'] }}</small>
@endif
@else
<span class="badge badge-secondary">N/A</span>
@endif
</td>
<td class="text-center">
<a href="{{ route('admin.backup.download', $backup['filename']) }}" class="btn btn-success btn-sm" title="Scarica">
<i class="fas fa-download"></i>
</a>
<form method="POST" action="{{ route('admin.backup.destroy', $backup['filename']) }}" style="display:inline" onsubmit="return confirm('Eliminare definitivamente questo backup?')">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger btn-sm" title="Elimina">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-5">
<i class="fas fa-hdd fa-3x text-muted mb-3"></i>
<p class="text-muted mb-0">Nessun backup disponibile.</p>
<p class="text-muted">Clicca "Esegui Backup Ora" per creare il primo backup.</p>
</div>
@endif
</div>
</div>
{{-- Info sul backup --}}
<div class="card card-outline card-info">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-info-circle mr-2"></i>Cosa Include il Backup</h3>
</div>
<div class="card-body p-0">
<table class="table table-bordered mb-0">
<thead>
<tr>
<th>Componente</th>
<th>Incluso</th>
<th>Dettagli</th>
</tr>
</thead>
<tbody>
<tr>
<td><i class="fas fa-database text-primary mr-2"></i> Database (MySQL)</td>
<td><span class="badge badge-success">Sempre</span></td>
<td>Struttura, dati, stored procedure, eventi, trigger</td>
</tr>
<tr>
<td><i class="fas fa-file text-warning mr-2"></i> File caricati</td>
<td>
@if($config['backup_include_files'])
<span class="badge badge-success"></span>
@else
<span class="badge badge-secondary">No</span>
@endif
</td>
<td>Documenti, avatar, gallerie dalla cartella storage</td>
</tr>
<tr>
<td><i class="fas fa-cog text-secondary mr-2"></i> Configurazione (.env)</td>
<td>
@if($config['backup_include_env'])
<span class="badge badge-success"></span>
@else
<span class="badge badge-secondary">No</span>
@endif
</td>
<td>APP_KEY, DB credentials, SMTP, OAuth token</td>
</tr>
<tr>
<td><i class="fas fa-tag text-info mr-2"></i> Metadati</td>
<td><span class="badge badge-success">Sempre</span></td>
<td>Manifest JSON con versione app, PHP, Laravel</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-md-4">
{{-- Configurazione --}}
<div class="card card-outline card-secondary">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-sliders-h mr-2"></i>Configurazione Backup</h3>
</div>
<form method="POST" action="{{ route('admin.backup.config') }}">
@csrf
<div class="card-body">
<div class="form-group">
<label for="backup_path">Percorso backup</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><code>storage/app/</code></span>
</div>
<input type="text" name="backup_path" id="backup_path" class="form-control" value="{{ $config['backup_path'] }}">
</div>
<small class="text-muted">Cartella dove salvare i backup (relativa a storage/app/)</small>
</div>
<div class="form-group">
<label for="backup_retention_days">Ritenzione (giorni)</label>
<input type="number" name="backup_retention_days" id="backup_retention_days" class="form-control" value="{{ $config['backup_retention_days'] }}" min="1" max="365">
<small class="text-muted">I backup più vecchi vengono eliminati automaticamente</small>
</div>
<div class="form-group mb-0">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="backup_include_files" name="backup_include_files" value="1" {{ $config['backup_include_files'] ? 'checked' : '' }}>
<label class="custom-control-label" for="backup_include_files">Includi file caricati</label>
</div>
<div class="custom-control custom-switch mt-2">
<input type="checkbox" class="custom-control-input" id="backup_include_env" name="backup_include_env" value="1" {{ $config['backup_include_env'] ? 'checked' : '' }}>
<label class="custom-control-label" for="backup_include_env">Includi .env (configurazione)</label>
</div>
</div>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-secondary btn-block">
<i class="fas fa-save mr-1"></i> Salva Configurazione
</button>
</div>
</form>
</div>
{{-- Backup automatico --}}
<div class="card card-outline card-warning">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-clock mr-2"></i>Backup Automatico</h3>
<div class="card-tools">
@php $autoEnabled = \App\Models\AppSetting::getSetting('backup_auto_enabled', false) @endphp
<form method="POST" action="{{ route('admin.backup.toggle-auto') }}" style="display:inline">
@csrf
<button type="submit" class="btn btn-sm {{ $autoEnabled ? 'btn-success' : 'btn-secondary' }}">
<i class="fas {{ $autoEnabled ? 'fa-check-circle' : 'fa-times-circle' }} mr-1"></i>
{{ $autoEnabled ? 'Attivo' : 'Disattivo' }}
</button>
</form>
</div>
</div>
<form method="POST" action="{{ route('admin.backup.save-auto') }}">
@csrf
<div class="card-body">
<div class="form-group">
<label for="backup_auto_frequency">Frequenza</label>
<select name="backup_auto_frequency" id="backup_auto_frequency" class="form-control">
<option value="daily" {{ \App\Models\AppSetting::getSetting('backup_auto_frequency', 'daily') === 'daily' ? 'selected' : '' }}>Giornaliero</option>
<option value="weekly" {{ \App\Models\AppSetting::getSetting('backup_auto_frequency', 'daily') === 'weekly' ? 'selected' : '' }}>Settimanale</option>
<option value="monthly" {{ \App\Models\AppSetting::getSetting('backup_auto_frequency', 'daily') === 'monthly' ? 'selected' : '' }}>Mensile</option>
</select>
</div>
<div class="form-group mb-0">
<label for="backup_auto_hour">Ora di esecuzione</label>
<select name="backup_auto_hour" id="backup_auto_hour" class="form-control">
@for($h = 0; $h <= 23; $h++)
<option value="{{ $h }}" {{ \App\Models\AppSetting::getSetting('backup_auto_hour', 3) == $h ? 'selected' : '' }}>{{ sprintf('%02d:00', $h) }}</option>
@endfor
</select>
<small class="text-muted">Ora del giorno (formato 24h). Consigliato: 03:00 (notte)</small>
</div>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-warning btn-block">
<i class="fas fa-save mr-1"></i> Salva Automatico
</button>
</div>
</form>
</div>
{{-- Migrazione --}}
<div class="card card-outline card-danger">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-truck mr-2"></i>Migrazione Server</h3>
</div>
<div class="card-body">
<p class="text-muted">Scarica il backup, poi esegui lo script di setup sul nuovo server per una migrazione completa.</p>
<a href="{{ route('help') }}#help-backup" class="btn btn-danger btn-block" target="_blank">
<i class="fas fa-book mr-1"></i> Guida alla Migrazione
</a>
</div>
</div>
</div>
</div>
{{-- Alert notifica cron --}}
<div class="alert alert-info">
<i class="fas fa-info-circle mr-2"></i>
<strong>Importante:</strong> Per il backup automatico, assicurati che il cron di Laravel sia configurato sul server:
<code class="ml-2">* * * * * cd {{ base_path() }} && php artisan schedule:run >> /dev/null 2>&1</code>
</div>
@endsection
@@ -31,6 +31,9 @@
<a href="#signature" class="list-group-item list-group-item-action" data-toggle="tab">
<i class="nav-icon fas fa-signature"></i> Firma
</a>
<a href="#mittenti" class="list-group-item list-group-item-action" data-toggle="tab">
<i class="nav-icon fas fa-user-plus"></i> Mittenti Aggiuntivi
</a>
</div>
</div>
</div>
@@ -245,6 +248,7 @@
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" class="custom-control-input" id="is_active"
name="is_active" value="1" {{ ($settings->is_active ?? false) ? 'checked' : '' }}>
<label class="custom-control-label" for="is_active">Account Email Attivo</label>
@@ -262,6 +266,7 @@
<div class="card-body">
<div class="form-group">
<div class="custom-control custom-switch">
<input type="hidden" name="signature_enabled" value="0">
<input type="checkbox" class="custom-control-input" id="signature_enabled"
name="signature_enabled" value="1" {{ ($settings->signature_enabled ?? true) ? 'checked' : '' }}>
<label class="custom-control-label" for="signature_enabled">Attiva firma automatica</label>
@@ -278,9 +283,103 @@
</div>
</div>
</div>
<div class="tab-pane" id="mittenti">
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">Mittenti Aggiuntivi</h3>
<div class="card-tools">
<button type="button" class="btn btn-sm btn-success" data-toggle="modal" data-target="#senderModal" onclick="resetSenderForm()">
<i class="fas fa-plus mr-1"></i> Nuovo Mittente
</button>
</div>
</div>
<div class="card-body">
@if(session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="alert alert-danger">{{ session('error') }}</div>
@endif
@if($senderAccounts->count() > 0)
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm">
<thead>
<tr>
<th>Email</th>
<th>Nome</th>
<th>SMTP</th>
<th>Reply-To</th>
<th>Verify</th>
<th>Stato</th>
<th style="width: 160px;">Azioni</th>
</tr>
</thead>
<tbody>
@foreach($senderAccounts as $sender)
<tr>
<td><strong>{{ $sender->email_address }}</strong></td>
<td>{{ $sender->email_name ?: '-' }}</td>
<td><small>{{ $sender->smtp_host ?: '-' }}:{{ $sender->smtp_port ?: '-' }}</small></td>
<td><small class="text-muted">{{ $sender->reply_to ?: '-' }}</small></td>
<td><small class="text-muted">{{ $sender->verify_email ?: '-' }}</small></td>
<td>
@if($sender->is_active)
<span class="badge badge-success">Attivo</span>
@else
<span class="badge badge-secondary">Disattivo</span>
@endif
</td>
<td>
<button type="button" class="btn btn-xs btn-info" onclick="editSender({{ $sender->id }})" title="Modifica">
<i class="fas fa-edit"></i>
</button>
<button type="button" class="btn btn-xs btn-success" onclick="testSenderSmtp({{ $sender->id }})" title="Test SMTP">
<i class="fas fa-paper-plane"></i>
</button>
<form action="{{ route('impostazioni.sender.destroy', $sender->id) }}" method="POST" class="d-inline" onsubmit="return confirm('Eliminare il mittente {{ $sender->email_address }}?')">
@csrf @method('DELETE')
<button type="submit" class="btn btn-xs btn-danger" title="Elimina">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center text-muted py-4">
<i class="fas fa-user-plus fa-2x mb-2"></i>
<p class="mb-0">Nessun mittente aggiuntivo configurato.</p>
<p class="mb-0">Usa il pulsante "Nuovo Mittente" per aggiungere un account di invio dedicato (es. Newsletter).</p>
</div>
@endif
</div>
</div>
@if($senderAccounts->count() > 0)
<div class="card card-secondary">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-info-circle mr-2"></i>Come funziona</h3>
</div>
<div class="card-body">
<ul class="mb-0">
<li>I mittenti aggiuntivi sono account <strong>solo invio</strong> (SMTP) senza IMAP.</li>
<li>Puoi selezionarli nel <strong>compose email</strong> o negli <strong>invii mailing</strong>.</li>
<li>Il campo <strong>Reply-To</strong> imposta l'indirizzo di risposta (es. <code>no-reply@parrocchia.it</code>).</li>
<li>Il campo <strong>Verify Email</strong> riceve un report di riepilogo dopo ogni invio massivo con l'esito (ok/falliti).</li>
<li>Se Reply-To non è impostato, viene usato Verify Email come fallback.</li>
</ul>
</div>
</div>
@endif
</div>
</div>
<div class="card-footer">
<div class="card-footer d-flex flex-wrap align-items-center gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save mr-1"></i> Salva Impostazioni
</button>
@@ -295,12 +394,193 @@
</a>
</div>
</form>
<div class="d-flex justify-content-end mt-2">
<form action="{{ route('impostazioni.email.destroy') }}" method="POST" onsubmit="return confirm('Eliminare definitivamente la configurazione email? Tutti i dati di connessione verranno rimossi.')">
@csrf @method('DELETE')
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-trash mr-1"></i> Elimina Configurazione
</button>
</form>
</div>
</div>
</div>
@endsection
<div class="modal fade" id="senderModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form id="senderForm" method="POST" action="{{ route('impostazioni.sender.store') }}">
@csrf
<input type="hidden" name="_method" id="senderMethod" value="POST">
<input type="hidden" name="id" id="senderId" value="">
<div class="modal-header bg-primary">
<h5 class="modal-title" id="senderModalTitle"><i class="fas fa-user-plus mr-2"></i>Nuovo Mittente</h5>
<button type="button" class="close text-white" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="sender_email">Email *</label>
<input type="email" name="email_address" id="sender_email" class="form-control" required placeholder="newsletter@parrocchia.it">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="sender_name">Nome visualizzato</label>
<input type="text" name="email_name" id="sender_name" class="form-control" placeholder="Newsletter Parrocchiale">
</div>
</div>
</div>
<hr>
<h6>Configurazione SMTP</h6>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="sender_smtp_host">Host SMTP</label>
<input type="text" name="smtp_host" id="sender_smtp_host" class="form-control" placeholder="smtp.gmail.com">
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label for="sender_smtp_port">Porta</label>
<input type="number" name="smtp_port" id="sender_smtp_port" class="form-control" value="587">
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label for="sender_smtp_encryption">Crittografia</label>
<select name="smtp_encryption" id="sender_smtp_encryption" class="form-control">
<option value="tls">TLS</option>
<option value="ssl">SSL</option>
<option value="none">Nessuna</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="sender_smtp_username">Username SMTP</label>
<input type="text" name="smtp_username" id="sender_smtp_username" class="form-control" placeholder="Lascia vuoto per usare l'email">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="sender_smtp_password">Password SMTP</label>
<input type="password" name="smtp_password" id="sender_smtp_password" class="form-control" placeholder="......">
<small class="text-muted">Lascia vuoto per non cambiare</small>
</div>
</div>
</div>
<hr>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="sender_reply_to">Reply-To <small class="text-muted">(no-reply)</small></label>
<input type="email" name="reply_to" id="sender_reply_to" class="form-control" placeholder="no-reply@parrocchia.it">
<small class="text-muted">Indirizzo di risposta per le email inviate</small>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="sender_verify_email">Verify Email <small class="text-muted">(report)</small></label>
<input type="email" name="verify_email" id="sender_verify_email" class="form-control" placeholder="verifica@parrocchia.it">
<small class="text-muted">Riceve report riepilogo dopo invii massivi</small>
</div>
</div>
</div>
<div class="form-group">
<label for="sender_note">Nota interna</label>
<textarea name="note" id="sender_note" class="form-control" rows="2" placeholder="Es. Newsletter settimanale"></textarea>
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="sender_is_active" name="is_active" value="1" checked>
<label class="custom-control-label" for="sender_is_active">Mittente attivo</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save mr-1"></i> Salva Mittente
</button>
</div>
</form>
</div>
</div>
</div>
@section('scripts')
<script>
let senderAccounts = @json($senderAccounts);
function resetSenderForm() {
document.getElementById('senderForm').action = '{{ route('impostazioni.sender.store') }}';
document.getElementById('senderMethod').value = 'POST';
document.getElementById('senderModalTitle').textContent = 'Nuovo Mittente';
document.getElementById('senderForm').reset();
document.getElementById('senderId').value = '';
document.getElementById('sender_is_active').checked = true;
document.getElementById('sender_smtp_password').placeholder = '';
document.getElementById('sender_smtp_password').required = false;
}
function editSender(id) {
const sender = senderAccounts.find(s => s.id === id);
if (!sender) return;
document.getElementById('senderForm').action = '{{ route('impostazioni.sender.update', '__ID__') }}'.replace('__ID__', id);
document.getElementById('senderMethod').value = 'PUT';
document.getElementById('senderModalTitle').textContent = 'Modifica Mittente - ' + sender.email_address;
document.getElementById('senderId').value = sender.id;
document.getElementById('sender_email').value = sender.email_address;
document.getElementById('sender_name').value = sender.email_name || '';
document.getElementById('sender_smtp_host').value = sender.smtp_host || '';
document.getElementById('sender_smtp_port').value = sender.smtp_port || 587;
document.getElementById('sender_smtp_encryption').value = sender.smtp_encryption || 'tls';
document.getElementById('sender_smtp_username').value = sender.smtp_username || '';
document.getElementById('sender_smtp_password').value = '';
document.getElementById('sender_smtp_password').placeholder = '......';
document.getElementById('sender_smtp_password').required = false;
document.getElementById('sender_reply_to').value = sender.reply_to || '';
document.getElementById('sender_verify_email').value = sender.verify_email || '';
document.getElementById('sender_note').value = sender.note || '';
document.getElementById('sender_is_active').checked = sender.is_active;
$('#senderModal').modal('show');
}
function testSenderSmtp(id) {
const email = prompt('Inserisci l\'indirizzo email a cui inviare il test:');
if (!email || !email.includes('@')) return;
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
$.ajax({
url: '{{ route('impostazioni.sender.test', '__ID__') }}'.replace('__ID__', id),
method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
data: { email: email },
timeout: 30000,
success: function(response) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane"></i>';
alert(response.success ? '✅ ' + response.message : '❌ ' + response.message);
},
error: function(xhr) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane"></i>';
const msg = xhr.responseJSON?.message || 'Errore di connessione';
alert('❌ ' + msg);
}
});
}
function testConnection() {
$.ajax({
url: '{{ route('impostazioni.email.test') }}',
@@ -359,9 +639,11 @@ function testSmtp() {
});
}
var quillEditorInstance = null;
function createEditor() {
var container = document.querySelector('#signature-editor');
if (!container || container.querySelector('.ql-toolbar')) return;
if (!container || quillEditorInstance) return;
var quill = new Quill('#signature-editor', {
theme: 'snow',
@@ -410,10 +692,12 @@ function createEditor() {
signatureInput.value = quill.root.innerHTML;
}
});
quillEditorInstance = quill;
}
function initSignatureEditor() {
if (document.querySelector('#signature-editor .ql-toolbar')) return;
if (quillEditorInstance) return;
if (typeof Quill === 'undefined') {
var script = document.createElement('script');
+6
View File
@@ -59,6 +59,12 @@
<p>Ruoli</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/backup" class="nav-link {{ request()->is('admin/backup*') ? 'active' : '' }}">
<i class="nav-icon fas fa-hdd"></i>
<p>Backup</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/activity-logs" class="nav-link {{ request()->is('admin/activity-logs*') ? 'active' : '' }}">
<i class="nav-icon fas fa-history"></i>
@@ -44,9 +44,10 @@
@endforeach
</select>
</div>
<input type="hidden" name="is_admin" value="0">
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="isAdmin" name="is_admin" value="1">
<input type="checkbox" class="custom-control-input" id="isAdmin" name="is_admin" value="1" {{ old('is_admin') === '1' ? 'checked' : '' }}>
<label class="custom-control-label" for="isAdmin">Superadmin (bypass ACL)</label>
</div>
</div>
@@ -63,12 +64,12 @@
</tr>
</thead>
<tbody>
@foreach(['individui', 'gruppi', 'eventi', 'documenti', 'mailing', 'viste'] as $module)
@foreach(\App\Models\User::MODULES as $module)
<tr>
<td><strong>{{ ucfirst($module) }}</strong></td>
<td><input type="radio" name="permissions[{{ $module }}]" value="0" {{ old('permissions.' . $module, '1') == '0' ? 'checked' : '' }}></td>
<td><input type="radio" name="permissions[{{ $module }}]" value="1" {{ old('permissions.' . $module, '1') == '1' ? 'checked' : '' }}></td>
<td><input type="radio" name="permissions[{{ $module }}]" value="2" {{ old('permissions.' . $module, '1') == '2' ? 'checked' : '' }}></td>
<td><input type="radio" name="permissions[{{ $module }}]" value="0" {{ old('permissions.' . $module, $module === 'settings' ? '0' : '1') == '0' ? 'checked' : '' }}></td>
<td><input type="radio" name="permissions[{{ $module }}]" value="1" {{ old('permissions.' . $module, $module === 'settings' ? '0' : '1') == '1' ? 'checked' : '' }}></td>
<td><input type="radio" name="permissions[{{ $module }}]" value="2" {{ old('permissions.' . $module, $module === 'settings' ? '0' : '1') == '2' ? 'checked' : '' }}></td>
</tr>
@endforeach
</tbody>
+8 -7
View File
@@ -17,7 +17,7 @@
<h3 class="card-title">Dati Utente</h3>
</div>
<div class="card-body">
<form method="POST" action="{{ url('/admin/utenti/' . $user->id) }}">
<form method="POST" action="/admin/utenti/{{ $user->id }}">
@csrf @method('PUT')
<div class="form-group">
<label>Nome *</label>
@@ -38,9 +38,9 @@
<div class="form-group">
<label>Stato</label>
<select name="status" class="form-control" required>
<option value="active" {{ $user->status === 'active' ? 'selected' : '' }}>Attivo</option>
<option value="suspended" {{ $user->status === 'suspended' ? 'selected' : '' }}>Sospeso</option>
<option value="pending" {{ $user->status === 'pending' ? 'selected' : '' }}>In attesa</option>
<option value="active" {{ old('status', $user->status) === 'active' ? 'selected' : '' }}>Attivo</option>
<option value="suspended" {{ old('status', $user->status) === 'suspended' ? 'selected' : '' }}>Sospeso</option>
<option value="pending" {{ old('status', $user->status) === 'pending' ? 'selected' : '' }}>In attesa</option>
</select>
</div>
<div class="form-group">
@@ -48,15 +48,16 @@
<select name="role_preset_id" class="form-control" id="rolePresetSelect">
<option value="">Personalizzato</option>
@foreach($rolePresets as $preset)
<option value="{{ $preset->id }}" {{ $user->role_preset_id == $preset->id ? 'selected' : '' }}>
<option value="{{ $preset->id }}" {{ old('role_preset_id', $user->role_preset_id) == $preset->id ? 'selected' : '' }}>
{{ $preset->name }} - {{ $preset->description }}
</option>
@endforeach
</select>
</div>
<input type="hidden" name="is_admin" value="0">
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="isAdmin" name="is_admin" value="1" {{ $user->isSuperAdmin() ? 'checked' : '' }}>
<input type="checkbox" class="custom-control-input" id="isAdmin" name="is_admin" value="1" {{ old('is_admin', $user->isSuperAdmin() ? '1' : '0') === '1' ? 'checked' : '' }}>
<label class="custom-control-label" for="isAdmin">Superadmin (bypass ACL)</label>
</div>
</div>
@@ -73,7 +74,7 @@
</tr>
</thead>
<tbody>
@foreach(['individui', 'gruppi', 'eventi', 'documenti', 'mailing', 'viste'] as $module)
@foreach(\App\Models\User::MODULES as $module)
<tr>
<td><strong>{{ ucfirst($module) }}</strong></td>
<td><input type="radio" name="permissions[{{ $module }}]" value="0" {{ ($user->permissions[$module] ?? 0) == 0 ? 'checked' : '' }}></td>
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<style>
body { font-family: 'Segoe UI', Tahoma, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #28a745; color: white; padding: 20px; text-align: center; border-radius: 5px 5px 0 0; }
.body { padding: 30px; background: #f9f9f9; border: 1px solid #ddd; }
.button { display: inline-block; padding: 12px 30px; background: #28a745; color: white; text-decoration: none; border-radius: 5px; font-weight: bold; }
.footer { margin-top: 20px; font-size: 12px; color: #999; text-align: center; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>{{ e($appName) }}</h2>
</div>
<div class="body">
<p>Ciao <strong>{{ e($user->name) }}</strong>,</p>
<p>Hai richiesto il reset della password per il tuo account.</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{ $resetUrl }}" class="button">Reimposta Password</a>
</p>
<p>Se non hai richiesto tu il reset, ignora questa email.</p>
<p>Il link è valido per 60 minuti.</p>
</div>
<div class="footer">
&copy; {{ date('Y') }} {{ e($appName) }}
</div>
</div>
</body>
</html>
@@ -0,0 +1,60 @@
<?php
use App\Models\AppSetting;
$appLogo = AppSetting::getLogoUrl();
$appName = AppSetting::getAppName() ?? 'Glastree';
?>
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reset Password - {{ e($appName) }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/admin-lte@3.2/dist/css/adminlte.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="hold-transition login-page">
<div class="login-box">
<div class="login-logo">
<a href="/">
@if($appLogo)
<img src="{{ $appLogo }}" alt="Logo" style="max-height: 80px; max-width: 200px;">
@else
<i class="fas fa-tree text-success"></i> <b>{{ e($appName) }}</b>
@endif
</a>
</div>
<div class="card">
<div class="card-body login-card-body">
<p class="login-box-msg">Password dimenticata? Inserisci la tua email e ti invieremo il link per reimpostarla.</p>
@if(session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
<form action="{{ route('password.email') }}" method="POST">
@csrf
<div class="input-group mb-3">
<input type="email" name="email" class="form-control @error('email') is-invalid @enderror" placeholder="Email" required value="{{ old('email') }}">
<div class="input-group-append">
<div class="input-group-text"><span class="fas fa-envelope"></span></div>
</div>
@error('email')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="row">
<div class="col-12">
<button type="submit" class="btn btn-success btn-block">Invia link reset</button>
</div>
</div>
</form>
<p class="mb-0 text-center mt-3">
<a href="{{ route('login') }}">Torna al login</a>
</p>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+2 -2
View File
@@ -59,7 +59,7 @@ $appOrgName = AppSetting::getOrgName() ?? '';
<div class="row">
<div class="col-8">
<div class="icheck-primary">
<input type="checkbox" id="remember">
<input type="checkbox" id="remember" name="remember">
<label for="remember">Ricordami</label>
</div>
</div>
@@ -69,7 +69,7 @@ $appOrgName = AppSetting::getOrgName() ?? '';
</div>
</form>
<p class="mb-0 text-center mt-3">
<a href="{{ route('register') }}" class="text-center">Non hai un account? Registrati</a>
<a href="{{ route('password.request') }}" class="text-center">Password dimenticata?</a>
</p>
</div>
</div>
+76
View File
@@ -0,0 +1,76 @@
@extends('layouts.adminlte')
@section('title', 'Il Mio Profilo')
@section('page_title', 'Il Mio Profilo')
@section('breadcrumbs')
<li class="breadcrumb-item"><a href="{{ route('dashboard') }}">Dashboard</a></li>
<li class="breadcrumb-item active">Profilo</li>
@endsection
@section('content')
<div class="row">
<div class="col-md-6">
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-user mr-2"></i>Informazioni Account</h3>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Nome</dt>
<dd class="col-sm-8">{{ e(Auth::user()->name) }}</dd>
<dt class="col-sm-4">Email</dt>
<dd class="col-sm-8">{{ e(Auth::user()->email) }}</dd>
<dt class="col-sm-4">Membro dal</dt>
<dd class="col-sm-8">{{ Auth::user()->created_at->format('d/m/Y') }}</dd>
</dl>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card card-warning">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-key mr-2"></i>Cambia Password</h3>
</div>
<form method="POST" action="{{ route('profile.password') }}">
@csrf
@method('PUT')
<div class="card-body">
@if(session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="alert alert-danger">{{ session('error') }}</div>
@endif
<div class="form-group">
<label for="current_password">Password attuale *</label>
<input type="password" name="current_password" id="current_password" class="form-control @error('current_password') is-invalid @enderror" required>
@error('current_password')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label for="password">Nuova password *</label>
<input type="password" name="password" id="password" class="form-control @error('password') is-invalid @enderror" required minlength="8">
<small class="text-muted">Minimo 8 caratteri</small>
@error('password')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label for="password_confirmation">Conferma nuova password *</label>
<input type="password" name="password_confirmation" id="password_confirmation" class="form-control" required minlength="8">
</div>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-warning">
<i class="fas fa-save mr-1"></i> Aggiorna Password
</button>
</div>
</form>
</div>
</div>
</div>
@endsection
-80
View File
@@ -1,80 +0,0 @@
<?php
use App\Models\AppSetting;
$appName = AppSetting::getAppName() ?? 'Glastree';
?>
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Registrati - {{ e($appName) }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/admin-lte@3.2/dist/css/adminlte.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="hold-transition register-page">
<div class="register-box">
<div class="login-logo">
<a href="/"><i class="fas fa-tree text-success"></i> <b>{{ e($appName) }}</b></a>
</div>
<div class="card">
<div class="card-body register-card-body">
<p class="login-box-msg">Crea un nuovo account</p>
<form action="{{ route('register') }}" method="POST">
@csrf
<div class="input-group mb-3">
<input type="text" name="name" class="form-control @error('name') is-invalid @enderror" placeholder="Nome completo" value="{{ old('name') }}" required>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-user"></span>
</div>
</div>
@error('name')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="input-group mb-3">
<input type="email" name="email" class="form-control @error('email') is-invalid @enderror" placeholder="Email" value="{{ old('email') }}" required>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-envelope"></span>
</div>
</div>
@error('email')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="input-group mb-3">
<input type="password" name="password" class="form-control @error('password') is-invalid @enderror" placeholder="Password" required>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-lock"></span>
</div>
</div>
@error('password')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="input-group mb-3">
<input type="password" name="password_confirmation" class="form-control" placeholder="Conferma password" required>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-lock"></span>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<button type="submit" class="btn btn-success btn-block">Registrati</button>
</div>
</div>
</form>
<p class="mb-0 text-center mt-3">
<a href="{{ route('login') }}" class="text-center">Hai già un account? Accedi</a>
</p>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
@@ -0,0 +1,63 @@
<?php
use App\Models\AppSetting;
$appName = AppSetting::getAppName() ?? 'Glastree';
?>
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reimposta Password - {{ e($appName) }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/admin-lte@3.2/dist/css/adminlte.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="hold-transition login-page">
<div class="login-box">
<div class="login-logo">
<a href="/"><b>{{ e($appName) }}</b></a>
</div>
<div class="card">
<div class="card-body login-card-body">
<p class="login-box-msg">Scegli una nuova password</p>
@error('email')
<div class="alert alert-danger">{{ $message }}</div>
@enderror
<form action="{{ route('password.update') }}" method="POST">
@csrf
<input type="hidden" name="token" value="{{ $token }}">
<div class="input-group mb-3">
<input type="email" name="email" class="form-control" placeholder="Email" required value="{{ old('email') }}">
<div class="input-group-append">
<div class="input-group-text"><span class="fas fa-envelope"></span></div>
</div>
</div>
<div class="input-group mb-3">
<input type="password" name="password" class="form-control @error('password') is-invalid @enderror" placeholder="Nuova password" required minlength="8">
<div class="input-group-append">
<div class="input-group-text"><span class="fas fa-lock"></span></div>
</div>
@error('password')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="input-group mb-3">
<input type="password" name="password_confirmation" class="form-control" placeholder="Conferma password" required minlength="8">
<div class="input-group-append">
<div class="input-group-text"><span class="fas fa-lock"></span></div>
</div>
</div>
<div class="row">
<div class="col-12">
<button type="submit" class="btn btn-success btn-block">Reimposta Password</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
@@ -0,0 +1,43 @@
@props(['cartelle', 'currentFolderId' => null, 'level' => 0, 'canWrite' => false, 'canDelete' => false])
@foreach($cartelle as $cartella)
@php
$isActive = $currentFolderId && (int) $currentFolderId === (int) $cartella->id;
$hasChildren = $cartella->children->count() > 0;
@endphp
<div class="list-group-item d-flex align-items-center py-1 px-2 {{ $isActive ? 'active' : '' }}"
style="padding-left: {{ 20 + $level * 20 }}px !important;">
<a href="/documenti?folder_id={{ $cartella->id }}"
class="d-flex align-items-center text-decoration-none flex-grow-1 {{ $isActive ? 'text-white' : '' }}"
style="min-width: 0;">
<i class="fas fa-folder {{ $isActive ? 'text-white' : 'text-warning' }} mr-2"></i>
<span class="small text-truncate">{{ $cartella->nome }}</span>
</a>
@if($canWrite || $canDelete)
<div class="folder-actions ml-1 flex-shrink-0">
@if($canWrite)
<button type="button" class="btn btn-xs btn-link p-0 mr-1" onclick='event.stopPropagation(); renameFolder({{ $cartella->id }}, {{ json_encode($cartella->nome) }})' title="Rinomina">
<i class="fas fa-pen {{ $isActive ? 'text-white' : 'text-muted' }}"></i>
</button>
<button type="button" class="btn btn-xs btn-link p-0 mr-1" onclick='event.stopPropagation(); moveFolder({{ $cartella->id }}, {{ json_encode($cartella->nome) }})' title="Sposta">
<i class="fas fa-folder-open {{ $isActive ? 'text-white' : 'text-info' }}"></i>
</button>
@endif
@if($canDelete)
<button type="button" class="btn btn-xs btn-link p-0" onclick='event.stopPropagation(); deleteFolder({{ $cartella->id }}, {{ json_encode($cartella->nome) }})' title="Elimina">
<i class="fas fa-times {{ $isActive ? 'text-white' : 'text-danger' }}"></i>
</button>
@endif
</div>
@endif
</div>
@if($hasChildren)
@include('documenti._folder_tree', [
'cartelle' => $cartella->children,
'currentFolderId' => $currentFolderId,
'level' => $level + 1,
'canWrite' => $canWrite,
'canDelete' => $canDelete,
])
@endif
@endforeach
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -10,9 +10,22 @@
<h3 class="card-title"><i class="fas fa-edit mr-2"></i>Nuova Email</h3>
</div>
<div class="card-body">
<form method="POST" action="{{ route('email.send') }}" id="emailForm">
<form method="POST" action="{{ route('email.send') }}" id="emailForm" enctype="multipart/form-data">
@csrf
@if($senderAccounts->count() > 0)
<div class="form-group">
<label for="mittente_id">Da (mittente)</label>
<select name="mittente_id" id="mittente_id" class="form-control">
<option value="0">Sistema {{ optional(\App\Models\EmailSetting::getActive())->email_address ?? 'Configura email' }}</option>
@foreach($senderAccounts as $sender)
<option value="{{ $sender->id }}">{{ $sender->email_name ?: $sender->email_address }} &lt;{{ $sender->email_address }}&gt;</option>
@endforeach
</select>
<small class="text-muted">Seleziona un mittente alternativo per l'invio. Lascia "Sistema" per usare l'account predefinito.</small>
</div>
@endif
<div class="form-group">
<label>Destinatari *</label>
<div class="mb-2">
+23 -2
View File
@@ -131,8 +131,29 @@ $currentFolder = $folders->firstWhere('type', $folder);
</table>
</div>
</div>
<div class="card-footer">
{{ $messages->withQueryString()->links('vendor.pagination.simple-bootstrap-4') }}
<div class="card-footer d-flex justify-content-between align-items-center">
<div>
<form method="GET" class="form-inline" id="perPageForm">
<label class="mr-2 small">Righe per pagina:</label>
<select name="perPage" class="form-control form-control-sm" onchange="this.form.submit()" style="width: auto;">
@foreach([10, 20, 25, 50, 100] as $p)
<option value="{{ $p }}" {{ (int) request('perPage', 20) === $p ? 'selected' : '' }}>{{ $p }}</option>
@endforeach
</select>
@foreach(request()->except(['perPage', 'page']) as $key => $value)
@if(is_array($value))
@foreach($value as $v)
<input type="hidden" name="{{ $key }}[]" value="{{ $v }}">
@endforeach
@else
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
</form>
</div>
<div>
{{ $messages->links('vendor.pagination.simple-bootstrap-4') }}
</div>
</div>
</div>
@endsection
+45 -2
View File
@@ -17,6 +17,15 @@
<a href="{{ route('eventi.create') }}" class="btn btn-success btn-sm">
<i class="fas fa-plus mr-1"></i> Nuovo Evento
</a>
<button type="button" class="btn btn-info btn-sm" id="syncCalendarsBtn" onclick="syncCalendars()">
<i class="fas fa-sync mr-1"></i> Sync
</button>
<form action="{{ route('eventi.export-selected-ics') }}" method="POST" class="d-inline">
@csrf
<button type="submit" class="btn btn-success btn-sm">
<i class="fas fa-calendar-plus mr-1"></i> Esporta ICS
</button>
</form>
</div>
</div>
<div class="card-body">
@@ -34,10 +43,44 @@
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js"></script>
<script>
function syncCalendars() {
var btn = document.getElementById('syncCalendarsBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Sync...';
fetch('{{ route('calendario-connessioni.sync-all') }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
'Content-Type': 'application/json',
},
})
.then(function(r) { return r.json(); })
.then(function(data) {
var msg = data.connections + ' connessioni sincronizzate.';
msg += ' Importati: ' + data.total_imported + ', Esportati: ' + data.total_exported + '.';
if (data.success) {
alert('✅ Sync completato!\n' + msg);
} else {
alert('⚠️ Sync completato con errori.\n' + msg);
}
if (window.calendar) {
window.calendar.refetchEvents();
}
})
.catch(function(err) {
alert('❌ Errore sync: ' + err.message);
})
.finally(function() {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-sync mr-1"></i> Sync';
});
}
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, {
window.calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
locale: 'it',
headerToolbar: {
@@ -91,7 +134,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
calendar.render();
window.calendar.render();
});
</script>
@endsection
+55
View File
@@ -0,0 +1,55 @@
@extends('layouts.adminlte')
@section('title', 'Importa Eventi')
@section('page_title', 'Importa Eventi da ICS')
@section('content')
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-upload mr-2"></i>Carica file ICS</h3>
</div>
<div class="card-body">
<form action="{{ route('eventi.import.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="form-group">
<label for="file">Seleziona file ICS *</label>
<input type="file" name="file" id="file" class="form-control" accept=".ics" required>
<small class="form-text text-muted">
Il file deve essere in formato ICS (iCalendar).
</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload mr-1"></i> Importa
</button>
<a href="/eventi" class="btn btn-secondary ml-2">
Annulla
</a>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-info-circle mr-2"></i>Informazioni</h3>
</div>
<div class="card-body">
<h6>Campi importati:</h6>
<ul>
<li><code>SUMMARY</code> Nome evento (obbligatorio)</li>
<li><code>DESCRIPTION</code> Descrizione</li>
<li><code>DTSTART</code> Data/Ora inizio</li>
<li><code>DTEND</code> Durata</li>
<li><code>LOCATION</code> Luogo</li>
<li><code>UID</code> ID esterno (per evitare duplicati)</li>
</ul>
<p class="text-muted small mt-2">
<i class="fas fa-info-circle mr-1"></i>
Gli eventi con UID già presente nel sistema verranno saltati automaticamente.
</p>
</div>
</div>
</div>
</div>
@endsection
+57 -2
View File
@@ -23,6 +23,9 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
<a href="/eventi/calendar" class="btn btn-sm btn-outline-primary mr-2">
<i class="fas fa-calendar-alt mr-1"></i> Calendario
</a>
<button type="button" class="btn btn-sm btn-success mr-2" onclick="exportSelectedIcs()">
<i class="fas fa-calendar-plus mr-1"></i> Esporta ICS
</button>
@if($canDeleteEventi)
<div class="btn-group mr-2">
<button type="button" class="btn btn-sm btn-danger" onclick="deleteSelected()">
@@ -31,6 +34,9 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
</div>
@endif
@if($canWriteEventi)
<a href="/eventi/import" class="btn btn-sm btn-warning mr-2">
<i class="fas fa-file-import mr-1"></i> Importa ICS
</a>
<a href="/eventi/create" class="btn btn-xs btn-success">
<i class="fas fa-plus mr-1"></i> Nuovo Evento
</a>
@@ -244,8 +250,29 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
</tbody>
</table>
</div>
<div class="card-footer">
{{ $eventi->withQueryString()->links() }}
<div class="card-footer d-flex justify-content-between align-items-center">
<div>
<form method="GET" class="form-inline" id="perPageForm">
<label class="mr-2 small">Righe per pagina:</label>
<select name="perPage" class="form-control form-control-sm" onchange="this.form.submit()" style="width: auto;">
@foreach([10, 20, 25, 50, 100] as $p)
<option value="{{ $p }}" {{ (int) request('perPage', 20) === $p ? 'selected' : '' }}>{{ $p }}</option>
@endforeach
</select>
@foreach(request()->except(['perPage', 'page']) as $key => $value)
@if(is_array($value))
@foreach($value as $v)
<input type="hidden" name="{{ $key }}[]" value="{{ $v }}">
@endforeach
@else
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
</form>
</div>
<div>
{{ $eventi->links() }}
</div>
</div>
</div>
@@ -311,5 +338,33 @@ function deleteSelected() {
$('#deleteModal').modal('show');
}
function exportSelectedIcs() {
const selectedIds = getSelectedIds();
const form = document.createElement('form');
form.method = 'POST';
form.action = '/eventi/export-ics';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '{{ csrf_token() }}';
let html = `<input type="hidden" name="_token" value="${csrfToken}">`;
if (selectedIds.length > 0) {
selectedIds.forEach(function(id) {
html += `<input type="hidden" name="ids[]" value="${id}">`;
});
} else {
const params = new URLSearchParams(window.location.search);
params.forEach(function(value, key) {
if (key === 'search') html += `<input type="hidden" name="search" value="${value}">`;
if (key === 'gruppo_id') html += `<input type="hidden" name="gruppo_id" value="${value}">`;
if (key === 'tipo') html += `<input type="hidden" name="tipo" value="${value}">`;
});
}
form.innerHTML = html;
document.body.appendChild(form);
form.submit();
}
</script>
@endsection
+4 -6
View File
@@ -211,11 +211,6 @@
</div>
@endif
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-file mr-2"></i>Documenti</h3>
@@ -304,10 +299,13 @@
</a>
<form action="/eventi/{{ $evento->id }}" method="POST" class="d-inline">
@csrf @method('DELETE')
<button type="submit" class="btn btn-danger" onclick="return confirm('Confermi l\'eliminazione di {{ $evento->nome_evento }}?')">
<button type="submit" class="btn btn-danger" onclick="return confirm('Confermi l\'eliminazione di {{ $evento->nome_evento }}?'.replace(/'/g, '\\\''))">
<i class="fas fa-trash mr-1"></i> Elimina
</button>
</form>
<a href="/eventi/{{ $evento->id }}/export-ics" class="btn btn-success">
<i class="fas fa-calendar-plus mr-1"></i> Esporta ICS
</a>
<a href="/eventi" class="btn btn-secondary">
<i class="fas fa-arrow-left mr-1"></i> Torna all'elenco
</a>
+92 -22
View File
@@ -152,8 +152,7 @@
</tr>
<tr id="membro-edit-{{ $membro->id }}" style="display:none;">
<td colspan="6">
<form action="/gruppi/{{ $gruppo->id }}/membri/{{ $membro->id }}" method="POST" class="mb-0">
@csrf @method('PUT')
<div class="mb-0">
<div class="row">
<div class="col-md-4">
<input type="text" class="form-control form-control-sm" value="{{ $membro->cognome }} {{ $membro->nome }}" disabled>
@@ -162,7 +161,7 @@
<input type="text" class="form-control form-control-sm" value="{{ $membro->codice_id }}" disabled>
</div>
<div class="col-md-3">
<select name="ruolo_ids[]" class="form-control form-control-sm" multiple size="3">
<select id="edit-membro-ruoli-{{ $membro->id }}" class="form-control form-control-sm" multiple size="3">
@foreach(\App\Models\Ruolo::attive() as $ruolo)
@php $selected = in_array($ruolo->id, $membro->getRuoloIdsForGruppo($gruppo->id)) @endphp
<option value="{{ $ruolo->id }}" {{ $selected ? 'selected' : '' }}>{{ $ruolo->nome }}</option>
@@ -170,14 +169,14 @@
</select>
</div>
<div class="col-md-2">
<input type="date" name="data_adesione" class="form-control form-control-sm" value="{{ $membro->pivot->data_adesione }}">
<input type="date" id="edit-membro-data-{{ $membro->id }}" class="form-control form-control-sm" value="{{ $membro->pivot->data_adesione }}">
</div>
<div class="col-md-1">
<button type="submit" class="btn btn-xs btn-success"><i class="fas fa-check"></i></button>
<button type="button" class="btn btn-xs btn-success" onclick="submitEditMembro({{ $membro->id }})"><i class="fas fa-check"></i></button>
<button type="button" class="btn btn-xs btn-secondary" onclick="cancelEditMembro({{ $membro->id }})"><i class="fas fa-times"></i></button>
</div>
</div>
</form>
</div>
</td>
</tr>
@endforeach
@@ -201,17 +200,13 @@
</div>
<div class="card-body p-0">
<div id="documento-add-form" style="display:none;" class="p-3 bg-light border-bottom">
<form action="/documenti" method="POST" enctype="multipart/form-data" class="mb-0">
@csrf
<input type="hidden" name="visibilita" value="gruppo">
<input type="hidden" name="visibilita_target_id" value="{{ $gruppo->id }}">
<input type="hidden" name="visibilita_target_type" value="App\Models\Gruppo">
<div class="mb-0">
<div class="row">
<div class="col-md-3">
<input type="text" name="nome_file" class="form-control form-control-sm" placeholder="Nome documento" required>
<input type="text" id="doc-nome-file" class="form-control form-control-sm" placeholder="Nome documento">
</div>
<div class="col-md-3">
<select name="tipologia" class="form-control form-control-sm" required>
<select id="doc-tipologia" class="form-control form-control-sm">
<option value="">Tipologia...</option>
<option value="documento">Documento</option>
<option value="statuto">Statuto</option>
@@ -221,14 +216,14 @@
</select>
</div>
<div class="col-md-4">
<input type="file" name="file" class="form-control form-control-sm" required>
<input type="file" id="doc-file" class="form-control form-control-sm">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-xs btn-success"><i class="fas fa-upload mr-1"></i>Carica</button>
<button type="button" class="btn btn-xs btn-success" onclick="submitUploadDocumentoGruppo()"><i class="fas fa-upload mr-1"></i>Carica</button>
<button type="button" class="btn btn-xs btn-secondary" onclick="hideDocumentoForm()"><i class="fas fa-times"></i></button>
</div>
</div>
</form>
</div>
</div>
@if($gruppo->documenti && $gruppo->documenti->count() > 0)
@@ -258,12 +253,9 @@
<i class="fas fa-eye"></i>
</button>
@endif
<form action="/documenti/{{ $documento->id }}" method="POST" class="d-inline">
@csrf @method('DELETE')
<button type="submit" class="btn btn-xs btn-danger" onclick="return confirm('Eliminare questo documento?')" title="Elimina">
<i class="fas fa-trash"></i>
</button>
</form>
<button type="button" class="btn btn-xs btn-danger" onclick="deleteDocumentoFromEdit({{ $documento->id }})" title="Elimina">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
@endforeach
@@ -473,5 +465,83 @@ function previewDocumento(id, mimeType) {
document.getElementById('previewDownloadBtn').href = '/documenti/' + id + '/download';
$('#previewModal').modal('show');
}
function submitEditMembro(membroId) {
var ruoloSelect = document.getElementById('edit-membro-ruoli-' + membroId);
var dataInput = document.getElementById('edit-membro-data-' + membroId);
var formData = new URLSearchParams();
var selectedRuoli = Array.from(ruoloSelect.selectedOptions).map(opt => opt.value).filter(v => v);
selectedRuoli.forEach(function(ruoloId) {
formData.append('ruolo_ids[]', ruoloId);
});
formData.append('data_adesione', dataInput ? dataInput.value : '');
fetch('/gruppi/{{ $gruppo->id }}/membri/' + membroId, {
method: 'PUT',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
})
.then(response => {
if (!response.ok) return response.text().then(text => { throw new Error(text); });
return response.json();
})
.then(data => {
if (data.success) window.location.reload();
else alert(data.error || 'Errore');
})
.catch(error => alert('Errore: ' + error.message));
}
function submitUploadDocumentoGruppo() {
var nomeFile = document.getElementById('doc-nome-file');
var tipologia = document.getElementById('doc-tipologia');
var fileInput = document.getElementById('doc-file');
if (!nomeFile.value || !tipologia.value || !fileInput.files[0]) {
alert('Compila tutti i campi');
return;
}
var formData = new FormData();
formData.append('_token', '{{ csrf_token() }}');
formData.append('visibilita', 'gruppo');
formData.append('visibilita_target_id', '{{ $gruppo->id }}');
formData.append('visibilita_target_type', 'App\\Models\\Gruppo');
formData.append('nome_file', nomeFile.value);
formData.append('tipologia', tipologia.value);
formData.append('file', fileInput.files[0]);
document.getElementById('documento-add-form').querySelector('.btn-success').disabled = true;
fetch('/documenti', {
method: 'POST',
body: formData,
headers: { 'Accept': 'application/json' }
})
.then(function() { window.location.reload(); })
.catch(function() { window.location.reload(); });
}
function deleteDocumentoFromEdit(documentoId) {
if (!confirm('Eliminare questo documento?')) return;
var formData = new FormData();
formData.append('_token', '{{ csrf_token() }}');
formData.append('_method', 'DELETE');
formData.append('_redirect', '{{ url()->current() }}');
fetch('/documenti/' + documentoId, {
method: 'POST',
body: formData,
headers: { 'Accept': 'application/json' }
})
.then(function() { window.location.reload(); })
.catch(function() { window.location.reload(); });
}
</script>
@endsection
+64
View File
@@ -0,0 +1,64 @@
@extends('layouts.adminlte')
@section('title', 'Importa Gruppi')
@section('page_title', 'Importa Gruppi da CSV')
@section('content')
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-upload mr-2"></i>Carica file CSV</h3>
</div>
<div class="card-body">
<form action="{{ route('gruppi.import.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="form-group">
<label for="file">Seleziona file CSV *</label>
<input type="file" name="file" id="file" class="form-control" accept=".csv,.txt" required>
<small class="form-text text-muted">
Il file deve essere in formato CSV con separatore virgola.
</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload mr-1"></i> Importa
</button>
<a href="{{ route('gruppi.index') }}" class="btn btn-secondary ml-2">
Annulla
</a>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header bg-info">
<h3 class="card-title text-white"><i class="fas fa-download mr-2"></i>Template</h3>
</div>
<div class="card-body">
<p>Scarica il template CSV per l'importazione:</p>
<a href="{{ route('gruppi.template') }}" class="btn btn-success btn-block">
<i class="fas fa-file-csv mr-1"></i> Scarica Template
</a>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-info-circle mr-2"></i>Formato CSV</h3>
</div>
<div class="card-body">
<h6>Campi:</h6>
<ul>
<li><code>nome</code> (obbligatorio)</li>
<li><code>descrizione</code></li>
<li><code>parent_id</code> (ID del gruppo padre)</li>
<li><code>diocesi_id</code> (ID della diocesi)</li>
<li><code>indirizzo_incontro</code></li>
<li><code>cap_incontro</code></li>
<li><code>città_incontro</code></li>
<li><code>sigla_provincia_incontro</code> (2 lettere)</li>
</ul>
</div>
</div>
</div>
</div>
@endsection
+27
View File
@@ -55,6 +55,9 @@ $visibleColumnsJson = json_encode($visibleColumns);
<i class="fas fa-save mr-1"></i> Salva Vista
</button>
@if($canWriteGruppi)
<a href="{{ route('gruppi.import') }}" class="btn btn-warning btn-sm mr-1">
<i class="fas fa-file-import mr-1"></i> Importa
</a>
<a href="{{ route('gruppi.create') }}" class="btn btn-success btn-sm">
<i class="fas fa-plus"></i> Nuovo Gruppo
</a>
@@ -174,6 +177,30 @@ $visibleColumnsJson = json_encode($visibleColumns);
</tbody>
</table>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div>
<form method="GET" class="form-inline" id="perPageForm">
<label class="mr-2 small">Righe per pagina:</label>
<select name="perPage" class="form-control form-control-sm" onchange="this.form.submit()" style="width: auto;">
@foreach([10, 20, 25, 50, 100] as $p)
<option value="{{ $p }}" {{ (int) request('perPage', 20) === $p ? 'selected' : '' }}>{{ $p }}</option>
@endforeach
</select>
@foreach(request()->except(['perPage', 'page']) as $key => $value)
@if(is_array($value))
@foreach($value as $v)
<input type="hidden" name="{{ $key }}[]" value="{{ $v }}">
@endforeach
@else
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
</form>
</div>
<div>
{{ $gruppi->links() }}
</div>
</div>
@else
<div class="card-body p-0">
<div class="tree-view" style="padding: 15px;">

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