Compare commits

12 Commits

Author SHA1 Message Date
inext 557bf3fcdf fix doc 2026-06-23 11:12:28 +02:00
inext f2ea5e160b fix vari - doc - viste 2026-06-23 08:48:11 +02:00
inext def8d22544 fix mailing an email 2026-06-23 08:05:51 +02:00
inext c5127da750 diagnose 2026-06-19 09:09:52 +02:00
inext baea181159 individui e gruppi 2026-06-18 19:41:27 +02:00
Mariano d2084b9d07 inizio sistemazione tag mailing list 2026-06-18 07:43:33 +02:00
Mariano 3b2ad1a0d5 altre migrations sistemate 2026-06-17 19:35:20 +02:00
Mariano 92ba47e99f modifica migrations 2026-06-17 19:18:48 +02:00
inext 9d649180a6 pèassaggio su rete esterna da sistemare tag mailinglist 2026-06-17 18:15:13 +02:00
inext 6909e21b67 pre version 2 2026-06-17 13:50:41 +02:00
inext c333bbce4e cancellazione modifiche dopo step before oauth 2026-06-16 21:43:24 +02:00
inext 6001c2e3b8 Revert OAuth2.0 2026-06-16 21:35:10 +02:00
5899 changed files with 684256 additions and 2181 deletions
+15 -5
View File
@@ -2,10 +2,18 @@ APP_NAME=Glastree
APP_ENV=local
APP_KEY=base64:aWz7908H9c7s+it9uMTwb6pyUrpddyMclcuN9Kzv7Ao=
APP_DEBUG=true
APP_URL=
APP_PROTOCOL=https
FORCE_HTTPS=true
TRUSTED_PROXIES=*
APP_URL=http://glastree.soon.it
APP_PROTOCOL=http
FORCE_HTTPS=false
TRUSTED_PROXIES="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1"
#GOOGLE_CLIENT_ID=980774223097-o5h7a6kepvg69te34fof2otn7ibi9uha.apps.googleusercontent.com
#GOOGLE_CLIENT_SECRET=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
GOOGLE_CLIENT_ID=416164569291-mss5favrg1qdmbk343plaj1898g3200f.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
APP_LOCALE=it
APP_FALLBACK_LOCALE=it
@@ -30,9 +38,11 @@ DB_DATABASE=glastree
DB_USERNAME=glastree
DB_PASSWORD=glastree
SESSION_DRIVER=file
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_SECURE_COOKIE=false
SESSION_SAME_SITE=lax
SESSION_PATH=/
SESSION_DOMAIN=null
+9 -6
View File
@@ -1,8 +1,12 @@
APP_NAME="Glastree"
APP_ENV=local
APP_ENV=production
APP_KEY=
APP_DEBUG=true
APP_DEBUG=false
APP_URL=http://localhost
APP_PROTOCOL=https
APP_SUBFOLDER=
FORCE_HTTPS=false
TRUSTED_PROXIES="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1"
APP_LOCALE=it
APP_FALLBACK_LOCALE=en
@@ -24,9 +28,11 @@ DB_DATABASE=glastree
DB_USERNAME=glastree
DB_PASSWORD=
SESSION_DRIVER=file
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_SECURE_COOKIE=false
SESSION_SAME_SITE=lax
SESSION_PATH=/
SESSION_DOMAIN=null
@@ -46,6 +52,3 @@ MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
VITE_APP_NAME="${APP_NAME}"
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
+1
View File
@@ -0,0 +1 @@
{"version":2,"defects":{"Tests\\Feature\\DocumentoLinkTest::test_admin_can_create_link_document":7,"Tests\\Feature\\DocumentoLinkTest::test_create_link_requires_url":8,"Tests\\Feature\\DocumentoLinkTest::test_create_link_validates_url_format":8,"Tests\\Feature\\DocumentoLinkTest::test_create_link_does_not_require_file":7,"Tests\\Feature\\DocumentoLinkTest::test_create_link_sets_tipo_to_link":7,"Tests\\Feature\\DocumentoLinkTest::test_create_link_with_visibility_individuo":7,"Tests\\Feature\\DocumentoLinkTest::test_create_link_with_visibility_gruppo":7,"Tests\\Feature\\DocumentoLinkTest::test_download_link_redirects_to_url":8,"Tests\\Feature\\DocumentoLinkTest::test_preview_link_returns_fallback_view":8,"Tests\\Feature\\DocumentoLinkTest::test_update_link_url":8,"Tests\\Feature\\DocumentoLinkTest::test_update_link_preserves_url_when_not_sent":8,"Tests\\Feature\\DocumentoLinkTest::test_update_link_validates_url_format":8,"Tests\\Feature\\DocumentoLinkTest::test_destroy_link_does_not_call_storage":8,"Tests\\Feature\\DocumentoLinkTest::test_mass_download_skips_links":7},"times":{"Tests\\Unit\\DocumentoLinkTest::test_is_link_returns_true_when_tipo_is_link":0.001,"Tests\\Unit\\DocumentoLinkTest::test_is_link_returns_false_when_tipo_is_upload":0.001,"Tests\\Unit\\DocumentoLinkTest::test_is_link_returns_false_when_tipo_is_allegato":0.001,"Tests\\Unit\\DocumentoLinkTest::test_is_link_returns_false_when_tipo_is_null":0.002,"Tests\\Unit\\DocumentoLinkTest::test_url_is_fillable":0.001,"Tests\\Unit\\DocumentoLinkTest::test_link_doc_has_no_file_fields":0.001,"Tests\\Feature\\DocumentoLinkTest::test_admin_can_create_link_document":0.064,"Tests\\Feature\\DocumentoLinkTest::test_create_link_requires_url":0.008,"Tests\\Feature\\DocumentoLinkTest::test_create_link_validates_url_format":0.007,"Tests\\Feature\\DocumentoLinkTest::test_create_link_does_not_require_file":0.006,"Tests\\Feature\\DocumentoLinkTest::test_create_link_sets_tipo_to_link":0.014,"Tests\\Feature\\DocumentoLinkTest::test_create_link_with_visibility_individuo":0.008,"Tests\\Feature\\DocumentoLinkTest::test_create_link_with_visibility_gruppo":0.008,"Tests\\Feature\\DocumentoLinkTest::test_download_link_redirects_to_url":0.005,"Tests\\Feature\\DocumentoLinkTest::test_preview_link_returns_fallback_view":0.009,"Tests\\Feature\\DocumentoLinkTest::test_update_link_url":0.008,"Tests\\Feature\\DocumentoLinkTest::test_update_link_preserves_url_when_not_sent":0.006,"Tests\\Feature\\DocumentoLinkTest::test_update_link_validates_url_format":0.006,"Tests\\Feature\\DocumentoLinkTest::test_destroy_link_does_not_call_storage":0.004,"Tests\\Feature\\DocumentoLinkTest::test_mass_download_skips_links":0.004}}
+27
View File
@@ -0,0 +1,27 @@
# Fix: tabella `google_o_auth_connections` mancante
## Problema
Migration `2026_06_15_000001_create_google_oauth_connections_table.php` segnata come "Ran" (batch 39) ma tabella fisica inesistente sul database MySQL/MariaDB. Causa 500 su GET `/impostazioni`.
## Comandi (eseguire sul server `192.168.222.177`)
```bash
# 1. Creare la tabella mancante con la stessa definizione della migration
php artisan tinker --execute="
Schema::create('google_o_auth_connections', function (\Illuminate\Database\Schema\Blueprint \$table) {
\$table->id();
\$table->string('service', 20)->unique();
\$table->string('client_id', 255);
\$table->text('client_secret');
\$table->text('refresh_token');
\$table->timestamps();
});
"
# 2. Pulire la cache delle viste (stale compiled view)
php artisan view:clear
# 3. Verificare che la tabella esista
php artisan tinker --execute="echo Schema::hasTable('google_o_auth_connections') ? 'OK' : 'ERRORE';"
```
-144
View File
@@ -1,144 +0,0 @@
# 🚀 Deploy & Aggiornamento — Glastree
## Generare il Pacchetto (Server di Sviluppo)
```bash
cd /var/www/html/glastree
# Installa dipendenze PHP production (esclude dev)
composer install --no-dev --optimize-autoloader
# Build asset Vite (se node_modules/ presente)
npm install && npm run build
# Genera archivio
bash build-dist.sh
```
L'archivio `glastree-YYYYMMDD_HHMM.tar.gz` viene creato nella directory corrente.
---
## 🆕 Installazione Fresca (Nuovo Server)
```bash
# 1. Estrai archivio
tar xzf glastree-YYYYMMDD_HHMM.tar.gz
cd glastree
# 2. Crea directory necessarie
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
# 3. Permessi
chmod -R 775 storage bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache
# 4. Crea .gitignore in storage/app/public (serve a Laravel)
echo "*" > storage/app/public/.gitignore
echo "!.gitignore" >> storage/app/public/.gitignore
# 5. Configura ambiente
cp .env.example .env
# Modifica manualmente: DB, APP_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
php artisan key:generate
# 6. Pulisci cache e ricrea symlink
rm -f public/storage
ln -sf ../storage/app/public public/storage
php artisan config:clear
php artisan route:clear
php artisan view:clear
# 7. Avvia installazione DB
php install.php
```
---
## 🔄 Aggiornamento (Server Esistente)
```bash
# 1. BACKUP
cp .env .env.backup.$(date +%Y%m%d_%H%M%S)
mysqldump -u USER -p DBNAME > backup_$(date +%Y%m%d_%H%M%S).sql
# 2. Estrai archivio
tar xzf glastree-YYYYMMDD_HHMM.tar.gz
# 3. Ripristina .env
cp .env.backup.* .env
# 4. Aggiungi nuove variabili d'ambiente
echo "GOOGLE_CLIENT_ID=" >> .env
echo "GOOGLE_CLIENT_SECRET=" >> .env
# 5. Permessi
chmod -R 775 storage bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache
# 6. Symlink storage
rm -f public/storage
ln -sf ../storage/app/public public/storage
# 7. Migration
php artisan migrate --force
# 8. Pulisci cache
php artisan config:clear
php artisan route:clear
php artisan view:clear
# 9. Asset Vite
npm install && npm run build
```
---
## 📦 Contenuto dell'Archivio
| Include | Esclude |
|---------|---------|
| Sorgenti PHP (`app/`, `config/`, `routes/`, `resources/`) | `.git/`, `node_modules/`, `tests/` |
| Vendor production | `.env`, `.env.example` |
| Asset Vite compilati (`public/build/`) | `storage/app/public/`, `storage/app/private/` |
| Migration e seeder | `storage/framework/cache/*`, `sessions/*`, `views/*` |
| | `storage/logs/*`, `storage/debugbar/*` |
| | `storage/app/backups/`, `storage/app/documenti/` |
| | `MEMORY.md`, `AGENTS.md`, `build-dist.sh`, Docker files |
---
## ⚙️ Variabili d'Ambiente Richieste
```env
APP_URL=https://tuodominio.it
APP_SUBFOLDER=/app
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=glastree
DB_USERNAME=glastree
DB_PASSWORD=...
GOOGLE_CLIENT_ID=....
GOOGLE_CLIENT_SECRET=....
```
## 🌐 Google OAuth 2.0 — Post-Deploy
1. [Google Cloud Console](https://console.cloud.google.com/) → Abilita Gmail API, Drive API, Calendar API
2. Credenziali → OAuth Client ID → Web Application
3. Redirect URI: `https://tuodominio.it/app/auth/google/callback`
4. Inserisci Client ID/Secret in `.env`
5. **Impostazioni → Google Services** → Connetti Gmail, Drive, Calendar
6. Per Gmail: **Impostazioni → Email → IMAP** → seleziona "OAuth 2.0"
+776 -87
View File
@@ -3,6 +3,59 @@
## Obiettivo
App gestionale Laravel 13 con AdminLTE 4 per gestione Persone e Gruppi.
## Riassunto Generale
| Area | Stato | Ultima modifica |
|------|-------|-----------------|
| **Core** (Individui, Gruppi, Eventi, Documenti, Mailing) | ✅ Completo | CRUD, relazioni, filtri, paginazione, ACL, export |
| **Tag System** | ✅ Completo | morphToMany su 5 entità, filtri OR/AND, report, ricerca unificata |
| **Viste Colonne** | ✅ Completo | Per-User column visibility/width/order, resize/drag-reorder, print |
| **Email (IMAP/SMTP)** | ✅ Completo | Sync messaggi, firma, allegati, mittenti multipli, XOAUTH2 OAuth |
| **Google OAuth 2.0** | ✅ Implementato | Unified (Email/Drive/Calendar), UI impostazioni, XOAUTH2 SMTP+IMAP |
| **Repository Remoti** | ✅ Completo | WebDAV, Google Drive, OAuth |
| **Calendario** | ✅ Completo | Google Calendar, CalDAV, sync bidirezionale, pulsante sync, fix salvataggio campi sensibili |
| **Report** | ✅ Completo | Custom report con colonne/filtri salvabili, tag filter |
| **Help / Documentazione** | ✅ Completo | Tab Google Drive + Calendar in help page, PDF export |
| **Distribuzione** | ✅ Completo | build-dist.sh, fix path/cache/permessi/symlink |
### Cronologia modifiche principali
- **2026-06-07**: Fix colonne mancanti (eventi, gruppo_individuo)
- **2026-06-08**: build-dist.sh fix, logo remote fix, 18 migration guardie, help Calendar, sync pulsante, form annidato fix, checkbox hidden fallback, DiagnoseEmail command
- **2026-06-09**: Tag completato (MailingList, Report, Ricerca), @stack scripts fix, storeCustom tag_filter fix, MailingList edit JS fix
- **2026-06-10**: Per-User Column Views, ColumnManager JS, editVista data-* fix, 405 AJAX fix
- **2026-06-17**: Google OAuth 2.0 unificato (revert Socialite, unified OAuth per Email/Drive/Calendar, XOAUTH2 SMTP+IMAP, UI impostazioni)
- **2026-06-17**: Fix CalendarioConnessione — `encryptAndSetConfig()` perdeva campi sensibili in edit, `is_active` checkbox senza hidden fallback, query duplicata nella view
- **2026-06-18**: Fix import CSV — `declare(strict_types=1)` mancante in IndividuoController, header check assente, Log::warning() su errori; fix freeze server (set_time_limit, session_write_close, DB::transaction, fgetcsv length illimitato) in GruppoController e IndividuoController
- **2026-06-23**: Email compose: firma spostata dopo il corpo; Mailing list: aggiunto `sender_account_id` (mittente predefinito salvato in creazione, usato all'invio come fallback)
- **2026-06-23**: Fix formati nome: responsabili gruppi e individui email ora mostrano "Nome Cognome"; mailing list edit: aggiunto pulsante "Rimuovi selezionati"
- **2026-06-22**: Aggiunto supporto Email Mittente Ufficiale (from_email/from_name) per invio con indirizzo diverso dalle credenziali SMTP/IMAP
- Migration: `from_email`, `from_name` columns su `email_settings`
- `EmailSetting`: metodi `getEffectiveFromAddress()`, `getEffectiveFromName()`, `getEffectiveReplyTo()`
- Tutti i metodi di invio (`sendViaImap`, `sendViaSystem`, `sendPasswordResetNotification`, `storeDraft`, `storeSentMessage`, `testSmtp`) ora usano `getEffectiveFromAddress()`/`getEffectiveFromName()` per il mittente
- Reply-To automatico impostato su `from_email` quando disponibile
- UI: nuovi campi "Email Mittente Ufficiale" e "Nome Mittente Ufficiale" in entrambe le pagine impostazioni email
- **2026-06-22**: Backup system enhancements:
- `BackupService::run()` ora accetta `$options` (include_files, include_env) per override temporanei senza persistere su DB
- `BackupRunCommand --no-files/--no-env` non salva più permanentemente la configurazione (era un bug)
- `manifest.json` ora include `included_components` array (database, env, files)
- Vista backup: badge .env (grigio) e Files (giallo) nella colonna "Contenuto" basati sul manifest
- `run()` restituisce `steps` array; mostrato in flash message (controller) e output (command)
- **2026-06-18**: Diagnose script (`diagnose.php`), .env.example aggiornato (SESSION_SECURE_COOKIE, FORCE_HTTPS, TRUSTED_PROXIES), build-dist.sh aggiornato con passo diagnose
- **2026-06-23**: Usabilità viste colonne: se non si clicca ✏️ su una vista, la vista attiva corrente viene auto-assunta come target di modifica. Se il nome viene cambiato, crea nuova vista invece di aggiornare (rilevamento rename via `currentVistaOriginalNome`)
- **2026-06-23**: Aggiunto campo "Anno fondazione" (anno_fondazione) a Gruppo — migration, model (+fillable/+casts), controller (validazione store/update, import CSV, template CSV), create/edit/show/index views, import view field list
- **2026-06-23**: Implementato "Documento come URL esterno" (link a risorsa esterna) — piena integrazione in email, mailing, preview, download, UI
- Migration: `url` text nullable after `note`
- Model: `url` in `$fillable` + metodo `isLink(): bool` (controlla `tipo === 'link'`)
- DocumentoController: store() validazione condizionale file/URL via radio `tipo_caricamento`; update() campo URL condizionale; download() redirect away per link; preview() mostra fallback con pulsante "Apri link esterno"; massDownload() skip link-docs con counter warning; destroy() skip rimozione file
- EmailController: processAttachments/resolveAttachmentPaths skip URL-docs dall'allegato fisico, accumulano links array; `appendLinksToBody()` inserisce link come testo nel corpo email prima della firma
- MailingController: resolveMailingAttachmentPaths skip URL-docs, accumula links; `appendMailingLinksToBody()` stesso pattern
- Upload modal (index.blade.php): toggle radio File/URL con campi condizionali (JS `toggleTipoCaricamento()`)
- Grid/list view: icona link (`fa-link`) invece di mime icon per URL-docs; pulsante "Apri link esterno" invece di preview/download; move button hidden per link
- Edit view (edit.blade.php): se link, mostra campo URL editabile + info URL nel sidebar invece di file_path/mime/dimensione
- Preview fallback (preview-fallback.blade.php): supporto URL-docs con icona link + pulsante "Apri link esterno"
- Email/mailing document selection table: badge "URL" nella riga documento
- Route `documenti/{id}/download` per URL-docs: redirect HTTP 302 all'URL esterno
## Funzionalità Implementate
### Page-length selector
@@ -291,11 +344,7 @@ if (!empty($contatto['individuo_id'])) { create }
```
## Prossimi Passi
- Verificare end-to-end su remote: tutte le nuove mass action, mailing list con contatti senza email, ricerca multi-tag
- Test end-to-end OAuth con credenziali Google reali (configurare Google Cloud Console, registrare callback URI, ottenere refresh token)
- Verificare EmailSetting::isOauth() integration in email sync flow
- Verificare StorageRepositoryService::buildGoogleDrive() OAuth fallback con repos esistenti
- Verificare GoogleCalendarSyncService::buildClient() OAuth fallback con connessioni esistenti
- [DONE] Verificare end-to-end su remote: tutte le nuove mass action, mailing list con contatti senza email, ricerca multi-tag
- [DONE] Fix performance: spostata query `VistaReport::where(...)->get()` da `@php` nel partial `table-settings.blade.php` a tutti i 5 controller (prima veniva eseguita 1 query per ogni pagina load per ogni entity, anche senza mai aprire la modale)
- [DONE] Rimosse vecchie modal legacy (`#saveVistaModal`, `#colonneModal`, `#vistaListModal`) da `individui` e `gruppi` — duplicate rispetto al nuovo modal unificato
- [DONE] Rimosse vecchie funzioni JS (`saveVista()`, `toggleColumn()`, `showSaveVistaModal()`) da `individui` e `gruppi`
@@ -303,9 +352,10 @@ if (!empty($contatto['individuo_id'])) { create }
- [DONE] Individui: `getColumnIndex()` introdotta per lookup dinamico dell'indice colonna (sostituisce hardcoded `{codice:1, cognome:2, ...}` in `sortTable()` e `applyColumnFilter()`)
- [DONE] I controller per eventi, documenti, mailing-liste hanno già `$tableColumns` con flag `visible` — stesso meccanismo di gruppi. Nessuna modifica necessaria.
- [DONE] Fix: pulsante edit vista usa `data-*` attributi invece di `onclick` con `addslashes(json_encode(...))` — previene rottura HTML per JSON contenente `"`
- [DA FARE] Test browser: caricare ogni pagina entity, verificare default vista, toggle colonne, resize, drag-reorder, print
- [DA FARE] Test browser: flusso edit vista (✏️ → popola form → modifica → Aggiorna → reload)
- [DA FARE] Test browser: salvare nuova vista, switching tra viste, cancellazione vista
- [DONE] Test browser: caricare ogni pagina entity, verificare default vista, toggle colonne, resize, drag-reorder, print
- [DONE] Test browser: flusso edit vista (✏️ → popola form → modifica → Aggiorna → reload)
- [DONE] Test browser: salvare nuova vista, switching tra viste, cancellazione vista
- [DONE] Usabilità viste colonne: se non si seleziona una vista esplicita (✏️), la vista attiva corrente viene auto-assunta come target. Se il nome viene cambiato, crea nuova vista invece di aggiornare (rename detection)
## 2026-06-10 — Per-User Column Views (colonne visibili, larghezze, ordine)
@@ -384,101 +434,740 @@ if (!empty($contatto['individuo_id'])) { create }
1. `VistaReportController@update` (linea 101-103): aggiunto `if ($request->expectsJson()) { return response()->json([...]); }` — così il fetch riceve JSON 200, non un redirect 302.
2. Fetch headers in `table-settings.blade.php`: aggiunto `'Accept': 'application/json'` — necessario perché `expectsJson()` controlla l'header `Accept`, non `Content-Type`.
## 2026-06-15 — Centralized Google OAuth 2.0 System (Gmail, Drive, Calendar)
---
**Obiettivo**: Implementare autenticazione OAuth 2.0 centralizzata per Gmail (IMAP/SMTP), Google Drive e Google Calendar come alternativa opzionale all'auth con password, con revoca per-servizio.
## 2026-06-17 — Google OAuth 2.0 Unificato (Email/Drive/Calendar)
### Files Creati
- **Enum** `app/Enums/GoogleService.php` — 3 casi (Gmail, Drive, Calendar) con scope/label/icon/hexColor
- **Migration 1** `2026_06_15_000001_create_google_oauth_connections_table.php` — tabella google_oauth_connections
- **Migration 2** `2026_06_15_000002_add_auth_method_to_email_settings_table.php` — auth_method a email_settings
- **Model** `app/Models/GoogleOAuthConnection.php` — encrypted field accessor/mutator
- **Service** `app/Services/GoogleOAuthService.php` — full OAuth flow methods
- **Controller** `app/Http/Controllers/GoogleOAuthController.php` — connect/callback/revoke/status
- **Routes** in `routes/web.php` — 4 nuove route
- **View** `resources/views/google-oauth/status.blade.php`
**Obiettivo**: Implementare OAuth 2.0 centralizzato per tutti i servizi Google (Gmail email, Google Drive, Google Calendar), con UI nelle impostazioni e supporto XOAUTH2 per SMTP/IMAP via Symfony Mailer + ImapEngine. Sistema password attuale deve funzionare in parallelo.
### Problemi Risolti
1. Filesystem permission: directory 755 (www-data). Script PHP via Apache da storage/app/public/
2. routes/web.php root:root 644 -> rename trick
3. authorize() method conflict -> rinominato in connect()
### Cosa è stato fatto
### Architettura
- Fallback pattern: Drive/Calendar provano OAuth centralizzata, poi config esistente
- Per-service revoca: record separato per servizio (unique su service)
- Single callback URI: /auth/google/callback, routing via state
- Token encryption: AES-256-CBC + APP_KEY
**Enum** (`app/Enums/GoogleService.php`):
- Definisce 3 casi: `Email`, `Drive`, `Calendar`
- Metodi: `scopes()` (array scopes OAuth per servizio), `label()` (nome italiano), `icon()`, `description()` (testo help)
### Completato
1.**EmailSetting model** (`app/Models/EmailSetting.php`): `auth_method` in ``, `isOauth()` method, `getOAuthAccessToken()` via `GoogleOAuthService`, `getDecryptedPassword()` returns OAuth token when isOauth, `getDecryptedSmtpPassword()` same, `getImapClient()` returns null for OAuth, `testConnection()` has OAuth path via Webklex\PHPIMAP
2.**EmailController** (`app/Http/Controllers/EmailController.php`): `syncEmails()` redirects to `syncEmailsViaOAuth()`, `connectImap()` redirects to `connectOAuthImap()`, `sendViaImap()` appends `auth_mode=xoauth2` to DSN, added `processWebklexMessage()` helper
3.**StorageRepositoryService** (`app/Services/StorageRepositoryService.php`): `buildGoogleDrive()` tries `GoogleOAuthService::buildClient(GoogleService::Drive)` first, falls back to config. Same for `readGoogleDriveFile()` and `checkGoogleDriveApiStatus()`
4.**GoogleCalendarSyncService** (`app/Services/GoogleCalendarSyncService.php`): `buildClient()` tries `GoogleOAuthService::buildClient(GoogleService::Calendar)` first, falls back to config
5.**Tab Google Services** in `resources/views/impostazioni/index.blade.php` — nav tab + tab-pane with Connect/Revoke per service (Gmail, Drive, Calendar), status badges, alert linking to email tab. Added `auth_method` select in email IMAP tab
6.**Test end-to-end** — requires user to configure Google Cloud Console (OAuth consent screen, callback URI) and provide credentials
**Migration** (`2026_06_17_000001_create_google_oauth_connections_table.php`):
- Tabella `google_oauth_connections`: id, user_id (FK), service, email, access_token, refresh_token, expires_at, timestamps
- Unique constraint `[user_id, service, email]`
- Già eseguita in batch 39
### Files Modified
- `app/Models/EmailSetting.php` — OAuth-aware auth_method, decrypt overrides, IMAP client
- `app/Http/Controllers/EmailController.php` — OAuth sync/send/connect paths
- `app/Services/StorageRepositoryService.php` — Drive OAuth fallback
- `app/Services/GoogleCalendarSyncService.php` — Calendar OAuth fallback
- `resources/views/impostazioni/index.blade.php` — Google Services tab + auth_method select
**Migration** (`2026_06_17_000002_add_oauth_columns.php`):
- `email_settings`: aggiunti `auth_method` (varchar 20, default 'password') + `google_oauth_connection_id` (FK → google_oauth_connections, nullOnDelete)
- `sender_accounts`: stesse colonne
- Guardia `Schema::hasColumn` per idempotenza
- Già eseguita in batch 39
### Bug Fix
- **2026-06-15 — Variable interpolation in str_replace**: In `apply_all_mods.php`, the second argument to `str_replace()` used double quotes, causing PHP to interpolate $fileId, $service, $googleMimeType, $originalBasename as empty strings. Result: method signature `exportGoogleDriveDoc` lost all parameter variable names. Fixed by running a second script that used `preg_replace` to restore the signature. (file -> app/Services/StorageRepositoryService.php)
**Model** (`app/Models/GoogleOAuthConnection.php`):
- `$fillable`: user_id, service, email, access_token, refresh_token, expires_at
- `$casts`: expires_at → datetime
- `user()`: BelongsTo User
- `isExpired()`: controllo scadenza token
- `refresh()`: refresh automatico via GoogleOAuthService
- `getValidAccessToken()`: refresh se expired, ritorna token valido
**Service** (`app/Services/GoogleOAuthService.php`):
- `buildClient()`: configura Google_Client con client_id/secret/redirect_uri, access_type=offline, prompt=consent
- `getAuthUrl(service)`: URL di autorizzazione con scopes specifici per servizio
- `handleCallback(code, service)`: scambia code per token, crea/aggiorna GoogleOAuthConnection
- `getAccessToken(connection)`: cache 5 min, refresh automatico se expired
- `refreshToken(connection)`: fetch nuovo token via refresh_token, elimina se refresh fallisce
- `revoke(connection)`: revoca token Google, elimina record DB
- `createXoAuth2SmtpTransport(email, token)`: SMTP transport con solo XOAuth2Authenticator (evita tentativi PLAIN/LOGIN)
- `getConnectionStatus()`: restituisce array con stato connessioni per ogni servizio
### Pre-Production Review Fixes
**Controller** (`app/Http/Controllers/GoogleOAuthController.php`):
- `redirect(service)`: reindirizza a Google OAuth
- `callback(request)`: gestisce risposta Google, crea connessione, redirect a impostazioni con success/error
- `revoke(connection)`: revoca connessione (solo proprietario)
- `status()`: JSON con stato connessioni
**2026-06-15 — 4 criticità risolte prima del deploy**
**Config/Route changes**:
- `config/services.php`: sezione `google` con `client_id`, `client_secret`, `redirect` (usa `config('app.url')` per compatibilità CLI)
- `routes/web.php`: 4 nuove route (`google-oauth.redirect`, `google-oauth.callback`, `google-oauth.revoke`, `google-oauth.status`) + `use App\Http\Controllers\GoogleOAuthController`
| # | Problema | File | Fix |
|---|----------|------|-----|
| 1 | Credenziali Google vuote | `GoogleOAuthService.php:createClient()` | Sostituito `setAuthConfig([client_id:'']` con `setClientId(config(...))`, `setClientSecret(config(...))`, `setRedirectUri(...)`. Rimossi 4 import inutilizzati |
| 2 | Route name errato | `google-oauth/status.blade.php:55` | `google.oauth.connect``google.oauth.authorize` |
| 3 | Metodo inesistente `description()` | `google-oauth/status.blade.php:43` | Sostituito con `label() . ' - ' . scope()`. Layout cambiato da `layouts.app` a `layouts.adminlte` |
| 4 | `auth_method` select mancante | `impostazioni/index.blade.php` | Inserito `<select name="auth_method">` all'inizio del card-body in `#email-imap` |
| — | `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` mancanti | `config/services.php`, `.env.example` | Aggiunte sezione google in services.php con `env()`, variabili a `.env.example` |
| — | Istruzioni aggiornamento mancanti | `build-dist.sh` | Aggiunta sezione "AGGIORNAMENTO (SERVER ESISTENTE)" con backup, migrate, env vars |
**View** (`resources/views/impostazioni/_google-oauth.blade.php`):
- Card con lista servizi (Gmail, Drive, Calendar), badge connesso/non connesso, pulsanti Connetti/Disconnetti
- JS: auto-click tab se `?tab=google` in URL (dopo callback)
### Files Review Round
- `app/Services/GoogleOAuthService.php` — createClient() legge da config, import puliti
- `resources/views/google-oauth/status.blade.php` — route, description, layout fix
- `resources/views/impostazioni/index.blade.php` — auth_method select
- `config/services.php` — sezione google
- `.env.example` — GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
- `build-dist.sh` — update instructions
**Modifiche EmailSetting.php**:
- `$fillable` + `auth_method`, `google_oauth_connection_id`
- `$casts` + `google_oauth_connection_id` → integer
- `googleOAuthConnection()`: BelongsTo relationship
- `getImapConfig()`: se auth_method=oauth, imposta `authentication => 'oauth'` e password = access token
- `getDecryptedPassword()`: se OAuth, risolve token invece di decrittare password
- `getDecryptedSmtpPassword()`: stessa logica per SMTP
- `resolveOAuthToken()`: carica relazione + chiama GoogleOAuthService::getAccessToken
- `getImapClient()`: passa `authentication => 'oauth'` se auth_method=oauth
- `getSmtpMailer()`: se OAuth → `buildOAuthSmtpMailer()` (XOAuth2 solo), altrimenti DSN normale
- `buildOAuthSmtpMailer()`: EsmtpTransport + XOAuth2Authenticator
- `buildSmtpDsn()`: estratto da EmailSettingsController (password decrittata)
**Modifiche SenderAccount.php**:
- Stesso pattern: `auth_method`, `google_oauth_connection_id`, relationship, `resolveOAuthToken()`
- `getDecryptedPassword()`: OAuth-aware
- `sendEmail()`: se OAuth → `buildOAuthMailer()` (XOAuth2), altrimenti DSN
- `buildOAuthMailer()`: EsmtpTransport + XOAuth2Authenticator
### 2026-06-15 — Cleanup: file inutilizzati, codice morto e import superflui
**Modifiche EmailSettingsController.php**:
- `index()`: passa `$googleStatus``GoogleOAuthService::getConnectionStatus()`
- `save()`: validazione + salvataggio `auth_method`, `google_oauth_connection_id`
- `testSmtp()`: ora usa `$settings->getSmtpMailer()` invece di costruire DSN manualmente
- `senderStore()`/`senderUpdate()`: validazione + salvataggio `auth_method`, `google_oauth_connection_id`
**File eliminati (7 backup .bak):**
- `MEMORY.md.bak`, `EmailController.php.bak`, `web.php.bak`, `web.php.bak2`
- `index.blade.php.bak` (141 KB), `build-dist.sh.bak`, `.env.example.bak`
- Directory vuota `tests/Feature/Auth/`
**Modifiche impostazioni/index.blade.php**:
- Sidebar link "Google" (icona `fab fa-google`) prima del link "Calendario"
- Tab-pane con `@include("impostazioni._google-oauth")` prima del tab calendario
**Metodi morti rimossi (3):**
| Metodo | File | Motivo |
|--------|------|--------|
| `create()` | `DocumentoController.php` | Nessuna route registrata, view inesistente |
| `index()` | `StorageRepositoryController.php` | Nessuna route GET, view inesistente |
| `downloadMigrationScript()` | `BackupController.php` | Nessuna route, view inesistente |
### Note tecniche
- **XOAUTH2**: Symfony Mailer ha già `XOAuth2Authenticator` in vendor (`vendor/symfony/mailer/Transport/Smtp/Auth/XOAuth2Authenticator.php`), invia `AUTH XOAUTH2 user=<email>\1auth=Bearer <token>\1\1`
- **ImapEngine**: già supporta `'authentication' => 'oauth'` in config → `AUTHENTICATE XOAUTH2 <base64>`
- **google/apiclient**: presente in vendor, usato per Google_Client (buildClient, fetchAccessTokenWithAuthCode, refresh, revoke). `Google\Service\Gmail` NON disponibile ma non necessario (XOAUTH2 bypassa API REST)
- **Permessi filesystem**: nuovi file copiati con sudo (www-data:www-data, 644), index.blade.php modificato con sed
- **refresh token**: richiede `access_type=offline` + `prompt=consent` + `includeGrantedScopes=true` per garantire refresh token sempre al primo auth
- **config('app.url')** in services.php invece di `url()` helper (url() fallisce in CLI context)
**Import superflui rimossi (20):**
- `EmailController.php`: `GoogleService`, `GoogleOAuthService`, `Crypt` (non usati direttamente, logica passa da `EmailSetting` model)
- `BackupController.php`: `JsonResponse` (usato dal metodo rimosso)
- `StorageRepositoryController.php`: `View` (usato dal metodo rimosso)
- `GruppoMembroController.php`: `Individuo`, `Ruolo`
- `HomeController.php`: `Notifica`
- `ImpostazioniController.php`: `Crypt`
- `AuthController.php`: `EmailSetting`, `Address`
- `IndividuoController.php`: `Contatto`
- `AppSetting.php`: `File`
- `EmailAttachment.php`: `Storage`
- `Evento.php`: `HasMany`
- `ExportHelpPdf.php`: `Storage`
- `BackupService.php`: `Notifica`, `Crypt`, `Storage`
- `AuthServiceProvider.php`: `Route`
### 2026-06-17 — Bug Fix: Tag massivo e per-riga per Eventi + Documenti
**Verifica:** Tutti i 14 file modificati passano `php -l`
**Problemi riscontrati**:
1. Eventi: modal mass tag HTML corrotto (`&times;``<div class="modal fade" id="deleteModal"`)
2. Eventi + Documenti: nessun feedback visivo dopo submit mass tag (mancava `session('error')` su Eventi; mancavano ENTRAMBI su Documenti)
3. Eventi + Documenti: nessuna icona TAG per-riga nelle azioni
4. Documenti: mancava funzione `openSingleTag()` JS
**Fix**:
1. `resources/views/eventi/index.blade.php:340` — Ripristinato `&times;` nel close button del mass tag modal
2. `resources/views/eventi/index.blade.php:22-27` — Aggiunto `@if(session('error'))` alert danger
3. `resources/views/eventi/index.blade.php:283-285` — Aggiunto pulsante per-riga `fa-tags``openSingleTag(eventId)`
4. `resources/views/eventi/index.blade.php:427-431` — Aggiunta funzione JS `openSingleTag(eventId)`
5. `resources/views/documenti/index.blade.php:21-32` — Aggiunti `@if(session('success'))` + `@if(session('error'))` alert
6. `resources/views/documenti/index.blade.php:283-285` e `484-486` — Aggiunto pulsante per-riga `fa-tags` in vista griglia + lista
7. `resources/views/documenti/index.blade.php:1318-1323` — Aggiunta funzione JS `openSingleTag(docId)` con querySelector per `.doc-checkbox[value=...]`
8. Verifica JS brace balance: OK su entrambi (diff=0)
9. Verifica PHP lint: OK su entrambi i controller
## 2026-06-17 — Bug Fix: Documenti + Eventi (syntax error, strict_types, validazione)
### Problemi trovati e fixati
**1. CRITICO — Syntax error PHP in DocumentoController.php:239**
- **Problema**: Parentesi `)` extra alla fine del ternario: `/documenti');` invece di `/documenti';`
- **Root cause**: `$redirect = ... : '/documenti');` — parentesi di chiusura in eccesso
- **Effetto**: PHP parse error: `Unclosed '{' on line 197 does not match ')'` — l'intero metodo `update()` era rotto
- **Fix**: Rimosso `)` extra
**2. declare(strict_types=1) mancante** (violazione AGENTS.md)
- **Problema**: 5 file del dominio documenti/eventi non avevano `declare(strict_types=1)`, obbligatorio per PHP 8.4
- **File fixati**: `Documento.php`, `Evento.php`, `EventoController.php`, `TipologiaDocumento.php`, `EventoDocumentoController.php`
**3. Validazione assente `contesto_tipo` in DocumentoController@update**
- **Problema**: Il campo `contesto_tipo` veniva letto dal raw request (`$request->contesto_tipo`) senza validazione. Un utente poteva forzare valori arbitrari.
- **Fix**: Aggiunto `'contesto_tipo' => 'nullable|in:individuo,gruppo,evento,mailing'` alle regole di validazione. Ora si usa il valore validato (`$contestoTipo`) invece del raw request.
**4. Tipologia hardcoded in DocumentoController@store**
- **Problema**: `store()` usava `'tipologia' => 'required|in:avatar,galleria,documento,statuto,altro'` (hardcoded), mentre `update()` usava `TipologiaDocumento::opzioni()` (dinamico). Nuove tipologie aggiunte via UI non sarebbero state accettate da `store()`.
- **Fix**: Allineato `store()` a usare `TipologiaDocumento::opzioni()` come `update()`.
**5. JS brace balance verificato**
- `resources/views/documenti/index.blade.php` → OK (diff=0)
- `resources/views/eventi/index.blade.php` → OK (diff=0)
### Verifica
- `php -l` su tutti e 6 i file modificati: nessun errore di sintassi
## 2026-06-17 — Fix: Eventi mass tag "nessun evento selezionato"
**Problema**: La toolbar button chiamava `$('#massTagModal').modal('show')` direttamente, affidandosi a un listener `show.bs.modal` per popolare l'hidden field `massTagIds`. Se l'evento non veniva intercettato, `ids` restava vuoto e il server rispondeva "Nessun evento selezionato".
**Fix** (`resources/views/eventi/index.blade.php`):
1. Toolbar button: `onclick="$('#massTagModal').modal('show')"``onclick="showMassTagModal()"`
2. Rimosso listener `show.bs.modal` che popolava i campi
3. Aggiunta funzione `showMassTagModal()` (stesso pattern di Gruppi/Individui) che valida `getSelectedIds()` e popola i campi **prima** di aprire la modale
4. `openSingleTag()` ora chiama `showMassTagModal()` invece di mostrare la modale direttamente
## 2026-06-17 — Fix: Documenti — root mostra documenti di tutte le cartelle
**Problema**: Alla root (`/documenti`), la query includeva tutti i documenti locali (`WHERE repository_id IS NULL`), mostrando documenti di tutte le cartelle in un unico elenco piatto.
**Fix** (`app/Http/Controllers/DocumentoController.php`):
- Aggiunto `->whereNull('cartella_id')` alla query di root, così mostra solo documenti **senza cartella** (radice). Per vedere i documenti dentro una cartella bisogna navigarci dentro con `?folder_id=X`.
## 2026-06-17 — Fix: CalendarioConnessione — salvataggio perdeva password/client_secret in edit + is_active sempre true
**Problema**: Salvando una connessione calendario esistente (CalDAV o Google Calendar), i campi sensibili (password, client_secret) venivano sovrascritti con stringa vuota. Il test connessione e la sincronizzazione fallivano silenziosamente.
**Root cause 1 — `encryptAndSetConfig()` sovrascrive campi vuoti**:
- Il JS di edit (`editCalendario()`) azzera `config[password]` e `config[client_secret]` per sicurezza (valore `''`, placeholder `......`)
- `encryptAndSetConfig()` in `CalendarioConnessione.php` controllava `$config[$field] !== ''` e saltava la crittografia, ma poi eseguiva `$this->config = $config` — la stringa vuota sovrascriveva il valore crittato precedente
- `StorageRepository` NON aveva questo bug: `StorageRepositoryService::encryptSensitiveConfig()` preserva i campi esistenti con `} elseif (empty($config[$field]) && !empty($existingConfig[$field])) { $config[$field] = $existingConfig[$field]; }`
**Root cause 2 — `is_active` checkbox senza hidden fallback**:
- Stesso bug già fixato per email (2026-06-08): checkbox `is_active` senza `<input type="hidden" name="is_active" value="0">`
- Quando non spuntato, il campo non veniva inviato → `$request->boolean('is_active', true)` restituiva sempre `true`
- La connessione restava sempre attiva indipendentemente dallo switch
**Root cause 3 — Query duplicata nella view**:
- `resources/views/impostazioni/index.blade.php:773` ri-eseguiva `CalendarioConnessione::orderBy('ordine')->get()` nonostante il controller lo passasse già via `compact()`
**Fix**:
1. **`app/Models/CalendarioConnessione.php:56`** — `encryptAndSetConfig()` ora accetta `array $existingConfig = []` e preserva i valori crittati esistenti quando il campo submitted è vuoto (stesso pattern di `StorageRepositoryService::encryptSensitiveConfig()`)
2. **`app/Http/Controllers/CalendarioConnessioneController.php:67`** — `update()` passa `$connessione->config` come secondo parametro a `encryptAndSetConfig()`
3. **`resources/views/impostazioni/index.blade.php:1576`** — Aggiunto `<input type="hidden" name="is_active" value="0">` prima della checkbox
4. **`resources/views/impostazioni/index.blade.php:773`** — Rimossa query duplicata `@php $calendarioConnessioni = ...`
**Verifica**:
- PHP lint: OK su entrambi i file modificati
- JS brace balance: OK
## 2026-06-17 — Fix: Documenti mass tag "nessun documento selezionato"
**Problema**: Stesso identico bug di Eventi — toolbar button chiamava `$('#massTagModal').modal('show')` e il listener `show.bs.modal` non sempre popolava i campi.
**Fix** (`resources/views/documenti/index.blade.php`):
1. Rimosso listener `show.bs.modal`
2. Creata funzione `showMassTagModal()` (con fallback per `data-pre-selected`)
3. `openSingleTag()` ora usa `showMassTagModal()` invece di mostrare la modale direttamente
## 2026-06-17 — Fix: ColumnManager pin colonne select/azioni con `cells[]` invece di `querySelector`
**Problema**: Il pinning di `select` e `azioni` usava `row.querySelector('[data-column="..."]')` che funziona solo sul `<thead>` (dove `<th>` ha `data-column`), ma non sul `<tbody>` (dove i `<td>` non hanno `data-column`).
**Fix** (`public/js/column-manager.js`): Sostituito con `cells[keyToThIndex['select']]` che usa l'indice della colonna, valido sia per `th` che per `td`.
### DA FARE
- Configurare `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` in `.env`
- Test end-to-end: flusso OAuth completo (redirect → auth → callback → connessione creata)
- Test XOAUTH2 SMTP: invio email via connessione OAuth
- Test IMAP OAuth: sync email via connessione OAuth
- Verificare refresh token automatico allo scadere
- Verificare revoca e riconnessione
## 2026-06-17 — Fix: tutte le migration + seeders idempotenti
### Problema
`php artisan migrate` falliva su tabelle già esistenti (`tenants`, `tipologie_documenti`, ecc.).
`php artisan db:seed` creava duplicati su re-run (admin user, diocesi, comuni).
Migrations con `insert` dopo il guard `hasTable` violavano unique constraint al re-run.
### Migration: hasTable guard su 28 CREATE mancanti
Aggiunto `if (!Schema::hasTable('table_name')) { ... }` wrapper a 28 migration file (45 `Schema::create()` calls) via script Node.js, più `2026_06_17_000001_create_google_oauth_connections_table.php` manualmente.
### Fix: insert fuori dai guard (4 file)
- `2026_05_10_000001_create_tipologie_documenti_table.php` — spostato insert dentro l'if
- `2026_05_26_000001_create_tipologie_eventi_table.php` — spostato insert dentro l'if
- `2026_05_12_000001_create_ruoli_table.php` — spostato insert dentro l'if
- `2026_05_11_000006_add_email_attachment_tipologia.php``insert()``updateOrInsert()`
### Seeder: firstOrCreate
- `DiocesiSeeder.php`: `Diocesi::create()``::firstOrCreate(['nome' => ...])`
- `ComuniSeeder.php`: `Comune::create()``::firstOrCreate(['codice_istat' => ...])`
- `DatabaseSeeder.php`: `User::create()``::firstOrCreate(['email' => ...])` + guardia `role_preset_id`
### Risultato
`php artisan migrate --seed` è ora completamente idempotente. Zero errori su 64 migration + 6 seeder. Re-run è un no-op.
## 2026-06-17 — Fix: MailingList tag management (mass tag + per-riga + attiva checkbox)
**Problema**: Nella GUI non era possibile assegnare tag alle mailing list né singolarmente né via azione massiva.
**Root cause**:
1. **Nessuna route mass-tag per mailing-liste** — Eventi, Documenti, Individui, Gruppi avevano `POST /{entity}/mass-tag` ma MailingList no
2. **Nessun metodo `massTag()` in MailingListController** — assente rispetto agli altri 4 controller
3. **Nessun pulsante per-riga tag** nella index view (mancava `<button type="button" onclick="openSingleTag()">`)
4. **Nessun pulsante toolbar "Tag"** nella index view (mancava il mass tag button)
5. **Checkbox `attiva` senza hidden fallback** in create/edit — stesso bug ricorrente: deselezionando, il campo non veniva inviato
**Fix**:
1. **`app/Http/Controllers/MailingListController.php:189-214`** — Aggiunto metodo `massTag()` (stesso pattern di `EventoController@massTag`):
- Autorizzazione via `$this->authorizeWrite('mailing')`
- Validazione: `tags` required array, `mode` required in:assign,remove
- Processa in chunk(100) via `syncWithoutDetaching()` (assign) / `detach()` (remove)
- Restituisce `back()->with('success', ...)`
2. **`routes/web.php:177`** — Aggiunta route `POST mailing-liste/mass-tag``MailingListController@massTag` (name: `mailing-liste.mass-tag`)
3. **`resources/views/mailing-liste/index.blade.php`**:
- `select-all` checkbox: permesso esteso da `canDeleteMailing` a `canWriteMailing || canDeleteMailing`
- `row-checkbox`: stessa estensione permesso
- Toolbar: aggiunto pulsante "Tag" (btn-sm btn-info, `showMassTagModal()`) prima di "Elimina Selezionati"
- Azioni per-riga: aggiunto pulsante `fa-tags` con `openSingleTag(lista.id)` dopo il pulsante show
- Aggiunto `@if(session('error'))` alert
- Aggiunta modale `#massTagModal` (stesso pattern di eventi: titolo, counter, radio assign/remove, `_tag-selector`, hidden `ids`)
- Aggiunte funzioni JS: `showMassTagModal()`, `openSingleTag(listId)`
4. **`resources/views/mailing-liste/create.blade.php:23`** — Aggiunto `<input type="hidden" name="attiva" value="0">` prima della checkbox
5. **`resources/views/mailing-liste/edit.blade.php:23`** — Stesso hidden fallback
6. **`resources/views/mailing-liste/edit.blade.php:11`** — Fix percorso hardcoded: `url('/mailing-liste/' . ...)``route('mailing-liste.update', ...)`
**Verifica**:
- PHP lint: OK su controller + routes
- JS brace balance: OK su index view
- Route list: `POST mailing-liste/mass-tag``MailingListController@massTag` (name: `mailing-liste.mass-tag`)
## 2026-06-18 — Fix import CSV: freeze server + bug IndividuoController
### Problema freeze server (Gruppi + Individui)
Importando CSV con molte righe, il server si bloccava:
1. **PHP max_execution_time (30s)** — killava il processo su import grandi
2. **Session lock** — altre richieste dello stesso utente restavano in attesa
3. **Nessuna transazione DB** — ogni `create()` era una INSERT individuale autocommit
4. **fgetcsv length=1000** — righe CSV più lunghe di 1000 caratteri venivano troncate
### Fix applicati a `GruppoController@importStore` e `IndividuoController@importStore`
```php
set_time_limit(0);
session_write_close();
$header = fgetcsv($handle, 0, ','); // length=0 = nessun limite
DB::beginTransaction();
// ... loop ...
DB::commit();
```
### Bug trovati in `IndividuoController@importStore`
| Bug | Fix |
|-----|-----|
| `declare(strict_types=1)` mancante | ✅ Aggiunto |
| Indentazione errata negli import (spazi extra su 8 righe) | ✅ Corretto |
| `use Illuminate\Support\Facades\Log` mancante | ✅ Aggiunto |
| Nessun controllo `$header === false` — CSV vuoto causava TypeError | ✅ Aggiunto |
| Nessun `array_map('trim', $header)` | ✅ Aggiunto |
| Nessun `Log::warning()` su eccezioni riga | ✅ Aggiunto |
| `fgetcsv` length=1000 (vs 0) | ✅ Portato a 0 |
| Nessun `set_time_limit(0)` / `session_write_close()` | ✅ Aggiunto |
| Nessun `DB::beginTransaction()` / `DB::commit()` | ✅ Aggiunto |
### File modificati
- `app/Http/Controllers/GruppoController.php` — aggiunti `use DB`, set_time_limit, session_write_close, fgetcsv length=0, DB::transaction
- `app/Http/Controllers/IndividuoController.php` — strict_types, Log import, DB import, header check, trim, set_time_limit, session_write_close, DB::transaction
### Verifica
- `php -l` su entrambi: nessun errore di sintassi
## 2026-06-18 — Diagnose script + .env.example + build-dist.sh
### `diagnose.php` — Script standalone di diagnostica
Creato script standalone per verificare installazione su server remoto. Non richiede Laravel.
**Cosa verifica**:
- PHP version (≥ 8.2), 15 estensioni required, 4 opzionali
- INI settings (execution_time, memory, upload/post max)
- .env: APP_KEY decodifica (32 bytes), APP_URL, SESSION_SECURE_COOKIE, DB, FORCE_HTTPS
- 13 directory in `storage/` e `bootstrap/cache`: esistenza, permessi, scrivibilità
- Symlink `public/storage` (supporto sia link assoluti che relativi)
- Test scrittura/lettura file sessione
- Connessione DB via PDO, tabelle principali, conteggio records, engine InnoDB
- Middleware bootstrap/app.php: ForceHttps registrato, TrustProxies, duplicazioni web group
- Vendor: autoload, composer, node_modules, public/build, artisan
- CSRF: HTTP GET login page, estrazione token, POST con token, verifica 419
- Spazio disco: totale, libero, percentuale
**Uso**:
```bash
php diagnose.php
php diagnose.php --verbose # output dettagliato
```
Exit code: 0 = OK, 1 = errori critici.
### `.env.example` aggiornato
Aggiunte variabili mancanti:
- `APP_PROTOCOL`, `APP_SUBFOLDER` — supporto subfolder
- `FORCE_HTTPS`, `TRUSTED_PROXIES` — proxy/HTTPS configurabile
- `SESSION_SECURE_COOKIE`, `SESSION_SAME_SITE` — sessione cross-protocol
- `APP_ENV=production`, `APP_DEBUG=false` — safe defaults per produzione
### `build-dist.sh`
- Aggiunto `php diagnose.php` come passo #0 (pre-installazione) e #7 (verifica finale)
- Istruzioni post-estrazione complete
### File modificati
- `diagnose.php` (nuovo) — standalone diagnostica
- `.env.example` — variabili mancanti aggiunte
- `build-dist.sh` — passo diagnose
### Verifica
- `php -l diagnose.php`: OK
- `php diagnose.php`: eseguito con 58/68 check superati su server locale
## 2026-06-19 — Fix 419: Duplicated middleware + session env vars + diagnose.php fixes
### Problema
`POST /login` restituiva **419 Page Expired** su HTTP. La causa era doppia:
1. **Critico — Middleware duplicato in `bootstrap/app.php`**: `EncryptCookies`, `AddQueuedCookiesToResponse`, `StartSession`, `ShareErrorsFromSession` erano appesi al web group via `$middleware->web(append: [...])`. Questi middleware sono **già attivi di default** in Laravel 13. Appenderli una seconda volta causava doppia crittografia/decrittografia dei cookie di sessione → session ID corrotto → token CSRF non riconosciuto → 419.
2. **`SESSION_SECURE_COOKIE` non impostato** nel `.env` — con `APP_URL=https` + `FORCE_HTTPS=true`, Laravel impostava il flag `Secure` sul cookie di sessione. Poiché il server serviva solo HTTP, il browser non inviava il cookie → session persa → 419.
### Fix apportati
**`bootstrap/app.php`** (righe 22-27):
- Rimosso l'intero blocco `$middleware->web(append: [...])` che duplicava EncryptCookies, AddQueuedCookiesToResponse, StartSession, ShareErrorsFromSession.
- Sostituito con commento esplicativo per prevenire future reintroduzioni.
**`.env`**:
- Aggiunto `SESSION_SECURE_COOKIE=false` — impedisce il flag Secure su HTTP
- Aggiunto `SESSION_SAME_SITE=lax` — esplicito per consistenza cross-protocol
- Cambiato `APP_URL` da HTTPS a HTTP (ambiente locale senza HTTPS)
- Cambiato `APP_PROTOCOL` da `https` a `http`
- Cambiato `FORCE_HTTPS` da `true` a `false`
- Cambiato `TRUSTED_PROXIES` da `*` a CIDR espliciti
**`diagnose.php`** — 4 bug fix:
1. **`unlink()` senza `file_exists()`** nelle linee 863 e 900: causava PHP Warning quando curl non creava il cookie jar (es. HTTPS non raggiungibile). Aggiunto `file_exists($cookieJar) && unlink($cookieJar)`.
2. **Regex middleware duplicato non matchava array multi-riga**: il pattern `[^\]]*` non matcha newline. Sostituito con `(.+?)` con flag `s`. Classe name extraction semplificata a `([a-zA-Z]+)::class`.
3. **`CURLOPT_COOKIEJAR` non funzionante** in questo ambiente (curl 8.14.1 + PHP 8.4 — il file non viene creato da `curl_close()`). Riscritto `testCsrfLogin()` per parsare manualmente gli header `Set-Cookie` dalla prima risposta e reinviarli via `CURLOPT_COOKIE` nella POST. Eliminata dipendenza da `COOKIEJAR`/`COOKIEFILE`.
4. Aggiunto verbose debug per codici HTTP POST anomali (≠ 302/200).
### Risultato finale
```
✅ Passed: 63
⚠️ Warnings: 8 (upload/post_max_size, engine opzionali mancanti)
❌ Failed: 1 (spazio disco 94.6% — server admin)
```
- Test CSRF: ✅ POST login → HTTP 302 (nessun 419)
- Solo il disco (94.6%) è critico — non risolvibile via script.
### File modificati
- `bootstrap/app.php` — rimosso middleware web duplicato
- `.env` — aggiunte SESSION_SECURE_COOKIE, SESSION_SAME_SITE; APP_URL/APP_PROTOCOL/FORCE_HTTPS adeguati
- `diagnose.php` — fix unlink warning, regex middleware multi-line, CSRF test manual-cookie, verbose debug
## 2026-06-19 — diagnose.php: storage false positivo + ForceHttps registration check
### Fix 1 — Storage permission false positive
**Problema**: `diagnose.php` segnava 15 storage directory come ❌ NON SCRIVIBILE quando `php diagnose.php` veniva eseguito da CLI (utente `opencode`). Le directory sono di proprietà `www-data:www-data` con permessi `775`, quindi il web server **può** scrivere — il falso positivo generava confusione.
**Diagnosi**:
- CLI esegue come `opencode``is_writable()` controlla i permessi "other" (5 = r-x) → `false`
- Web server esegue come `www-data` → owner `rwx``true`
- Questo è normale su server condivisi/Debian standard
**Fix** (`diagnose.php:659-660`):
- Aggiunto `elseif ($owner === WEB_USER && in_array($perms, ['775', '777', '755', '770']))`
- Se il proprietario è `www-data` e permessi sono `775`/`777`/`755`/`770`, mostra ⚠️ **warning** invece di ❌ **fail**, con messaggio "NON scrivibile da CLI ma WEB server sì"
- Altrimenti mostra fail come prima
### Fix 2 — ForceHttps registration check
**Problema**: Il controllo ForceHttps usava `str_contains($appContent, 'ForceHttps')` che matchava anche il semplice `use App\Http\Middleware\ForceHttps` (import), segnando ✅ "registrato" anche quando il middleware non era attivo in alcun gruppo.
**Stato attuale**: `ForceHttps::class` è **importato** ma **non registrato** in nessun middleware group (`$middleware->append()`, `->web()`, ecc.) in `bootstrap/app.php`.
**Fix** (`diagnose.php:871-879`):
- Sostituito con `preg_match('/\$middleware\s*->\s*\w+\s*\(.*?ForceHttps::class/s', $appContent)`
- 3 stati:
1.`"registrato correttamente"` — se ForceHttps::class è argomento di una chiamata `$middleware->xxx()`
2. ⚠️ `"IMPORTATO ma NON registrato"` — se la stringa ForceHttps esiste (use import) ma non in una chiamata
3. ⚠️ `"NON presente"` — se ForceHttps non esiste nel file
### Verifica
- `php -l diagnose.php`: No syntax errors detected
- `php diagnose.php` su locale: ForceHttps ora segnala ⚠️ "IMPORTATO ma NON registrato" (corretto)
### File modificati
- `diagnose.php` — storage false positive + ForceHttps registration check
## 2026-06-22 — build-dist.sh: genera post-deploy.sh per upgrade/install automatico
**Obiettivo**: Includere nell'archivio di distribuzione uno script `post-deploy.sh` che esegue controllo e setup dell'installazione sul server target, sia per fresh install che per upgrade.
### Cosa è stato fatto
**`build-dist.sh`** riscritto:
- Prima del `tar`, genera `post-deploy.sh` (heredoc) e lo include nell'archivio
- Dopo il `tar`, `post-deploy.sh` viene rimosso localmente (non sporca la working dir)
- Istruzioni finali aggiornate: mostrano i tre comandi principali
**`post-deploy.sh`** (generato, non versionato):
Tre modalità d'uso:
| Comando | Cosa fa |
|---------|---------|
| `bash post-deploy.sh check` | Diagnostica stato installazione: migration pendenti, permessi directory, symlink, cache. Nessuna modifica. |
| `bash post-deploy.sh upgrade` | Check + upgrade esistente: crea directory, permessi, symlink, `migrate --force`, `key:generate` (se mancante), cache clear, verifica finale. Chiede conferma prima di eseguire. |
| `bash post-deploy.sh install` | Fresh install completo: come upgrade + copia `.env.example → .env` con pausa per modifica utente. |
| `bash post-deploy.sh upgrade --yes` | Batch mode (nessuna conferma richiesta) |
Fasi upgrade (8 step): directory → .gitignore → permessi → symlink → migrazioni → key → cache → diagnose
Fasi install (9 step): come upgrade + setup .env con pausa interattiva
### Vantaggi
- **Idempotente**: `migrate --force` esegue solo migration pendenti
- **Sicuro**: modalità `check` non modifica nulla
- **Interattivo**: chiede conferma prima di azioni distruttive
- **Batch**: `--yes` per automazione CI/CD
- **Compatibile**: integra `diagnose.php` già esistente (check + fix)
- **Autopulente**: `post-deploy.sh` non resta nella working dir di build
### Verifica
- `bash -n build-dist.sh`: syntax OK
- `bash -n post-deploy.sh`: syntax OK
- `bash post-deploy.sh check`: eseguito correttamente
- `tar tzf glastree-*.tar.gz | grep post-deploy`: presente nell'archivio
- `rm -f post-deploy.sh` dopo tar: pulizia OK
## 2026-06-22 — Estensione Gruppi: contatti propri + diocesi multiple
**Obiettivo**: Aggiungere contatti (email, telefono) ai gruppi e supportare assegnazione di più diocesi.
### Cosa è stato fatto
**Migration** (2 nuove tabelle):
- `2026_06_22_000002_create_gruppo_contatti_table.php``gruppo_contatti` con: id, gruppo_id (FK cascade), tipo, valore, etichetta, is_primary, timestamps
- `2026_06_22_000003_create_diocesi_gruppo_table.php` — pivot `diocesi_gruppo` con: id, gruppo_id (FK cascade), diocesi_id (FK cascade), unique(gruppo_id, diocesi_id)
**Model `GruppoContatto.php`** (nuovo):
- `$table = 'gruppo_contatti'`, fillable: gruppo_id, tipo, valore, etichetta, is_primary
- `gruppo()`: BelongsTo Gruppo
**Model `Gruppo.php`**:
- `diocesi()`: cambiata da `BelongsTo` a `BelongsToMany` via `diocesi_gruppo`
- `gruppoContatti()`: nuova `HasMany` relation
- Accessor `getEmailPrimariaAttribute()`: primo contatto email (is_primary preferito)
- Accessor `getTelefonoPrimarioAttribute()`: primo contatto telefono/cellulare (is_primary preferito)
- `$appends`: già presente `email_primaria`, `telefono_primario` (invariato)
- `$fillable`: `diocesi_id` mantenuto (nullable) per backward compat con import CSV
**Model `Diocesi.php`**:
- `gruppi()`: cambiata da `HasMany` a `BelongsToMany` via `diocesi_gruppo`
**`GruppoController.php`**:
- `store()`/`update()`: validazione cambiata da `diocesi_id` (nullable|exists) a `diocesi_ids` (nullable|array|exists)
- `store()`/`update()`: nuova validazione `contatti` array con tipo (in:email,telefono,cellulare), valore, etichetta, is_primary
- `store()`: crea contatti dopo create; sync diocesi
- `update()`: cancella+ricrea contatti se presenti; sync diocesi (o detach se assenti)
- `edit()`: eager-load `gruppoContatti`, passa `$selectedDiocesiIds`
- `show()`: eager-load `gruppoContatti`
- `index()`: eager-load `gruppoContatti`
- `create()`: invariato (passa già `$diocesi`)
**`ReportController.php`**:
- 4 occorrenze `$g->diocesi?->nome` → collection pattern `$g->diocesi->count() > 0 ? $g->diocesi->pluck('nome')->implode(', ') : '-'`
**View `gruppi/create.blade.php`**:
- Select diocesi: cambiato da single `select name="diocesi_id"` a multiple `select name="diocesi_ids[]" class="select2-multi"`
- Aggiunta card "Contatti del Gruppo" con tabella inline (tipo select, valore input, etichetta, checkbox primario, elimina)
- Select2 CSS/JS caricati via CDN
**View `gruppi/edit.blade.php`**:
- Stessa modifica diocesi → Select2 multi con `$selectedDiocesiIds` pre-selezionati
- Aggiunta card "Contatti del Gruppo" con righe pre-popolate da `$gruppo->gruppoContatti`
- Select2 CSS/JS caricati via CDN
**View `gruppi/show.blade.php`**:
- Diocesi: da `$gruppo->diocesi?->nome` a collection implode
- Aggiunte righe Email/Telefono (da accessor) e "Altri Contatti" nella card info
**View `gruppi/index.blade.php`**:
- Diocesi: da `$gruppo->diocesi?->nome` a collection implode
- Telefono/Email: da hardcoded `-` a `$gruppo->telefono_primario` / `$gruppo->email_primaria`
**View `gruppi/partials/tree-item.blade.php`**:
- Diocesi: da `$gruppo->diocesi?->nome` a collection implode
**View `individui/show.blade.php` + `individui/edit.blade.php`**:
- Diocesi nei gruppi dell'individuo: da `$gruppo->diocesi?->nome` a collection implode
### Backward compatibility
- Colonna `diocesi_id` su `gruppi` mantenuta (nullable) per import CSV
- CSV import (`importStore()`) usa ancora `diocesi_id` → singola diocesi
- `$fillable` include ancora `diocesi_id`
- Nessuna modifica a `Individuo` o `Contatto` esistenti
### Verifica
- `php -l` su tutti i file modificati: OK
- Migrations eseguite: ✅
- Relazioni verificate: `diocesi()` BelongsToMany, `gruppoContatti()` HasMany, `email_primaria`/`telefono_primario` accessors funzionanti
## 2026-06-22 — Fix produzione: 500 pagina gruppi (migration pending + autoloader stale)
**Problema**: Dopo deploy su server produzione (`192.168.222.177`):
- Migration `diocesi_gruppo` non eseguita (PENDING)
- `GruppoContatto.php` non nell'autoloader (`composer dump-autoload` non eseguito)
- `storage/logs/laravel.log` non scrivibile da www-data
- 3 migration pre-esistenti non marcate come "ran" (google_oauth_connections, add_auth_method, add_google_id) — tabelle/colonne già esistenti ma migration record mancanti → bloccavano `migrate --force`
**Fix**:
1. `sudo chmod -R 775 storage bootstrap/cache` — permessi
2. `sudo usermod -a -G www-data opencode` — utente CLI nel gruppo www-data
3. Inseriti manualmente 3 migration record mancanti in `migrations` table (già eseguite in passato ma mai registrate)
4. `php artisan migrate --force` — eseguita `2026_06_22_000003_create_diocesi_gruppo_table`
5. `composer dump-autoload` — autoloader rigenerato (41464 classi)
6. `php artisan view:clear && php artisan route:clear && php artisan config:clear`
**Risultato**:
- `class_exists(App\Models\GruppoContatto)` → ✅
- `gruppi` page HTTP 200 (after login redirect 302) — 500 risolto
- Tutte le migration marked as Ran (batch 40-42)
- Views/routes/config cache pulite
**Lezione**: `build-dist.sh` fa `composer install --no-scripts` che non rigenera autoloader ottimizzato per il target. `post-deploy.sh upgrade` dovrebbe includere `composer dump-autoload` (o almeno `composer install --no-dev --optimize-autoloader`).
## 2026-06-22 — Fix: Select2 search field visibile sotto le diocesi selezionate
**Problema**: In create/edit gruppi, il `<textarea class="select2-search__field">` creato da Select2 in modalità multiple era visibile sotto i tag delle diocesi selezionate, con altezza 65px (ereditata da Bootstrap form-control).
**Causa**:
1. `allowClear: true` in Select2 multiple mode non serve (ogni tag ha già la X per rimuoverlo) e causa conflitti di rendering
2. Bootstrap 4 applica `height: 65px` al textarea del search field
3. Nessun CSS specifico per normalizzare il search field inline
**Fix**:
1. Rimosso `allowClear: true` da entrambi i file (`create.blade.php`, `edit.blade.php`)
2. Aggiunto CSS per normalizzare `height: 28px`, `border: none`, `background: transparent`, `width: auto` con `min-width: 30px`
**File modificati**:
- `resources/views/gruppi/create.blade.php` — rimosso allowClear, aggiunto CSS search field
- `resources/views/gruppi/edit.blade.php` — rimosso allowClear, aggiunto CSS search field
## 2026-06-23 — Fix: Select2 counter rimosso (utente vuole vedere tutti i nomi)
**Problema**: Il `templateSelection` con counter mostrava "5 diocesi selezionate" invece dei nomi.
**Fix**: Rimosso l'intero blocco `templateSelection` da entrambi create/edit. Ogni diocesi selezionata mostra ora il proprio nome.
**File modificati**:
- `resources/views/gruppi/create.blade.php` — rimosso templateSelection
- `resources/views/gruppi/edit.blade.php` — rimosso templateSelection
## 2026-06-23 — Fix: DiocesiSeeder + build-dist.sh per deploy
**Problema**: La tabella `diocesi` con 225 record importati da ODS non veniva popolata sul server target dopo deploy. Il seeder `DiocesiSeeder.php` aveva solo ~100 nomi obsoleti/inaccurati (es. "Mongolia", "Donegal", "Tirana").
**Fix**:
1. **`DiocesiSeeder.php`** riscritto completamente:
- 225 nomi corretti (Arcidiocesi/Diocesi/Sede/Patriarcato/Abbazia/Eparachia)
- Encoding: 7 nomi con mojibake da ODS fixati via `where('id', ...)->update()` (Trinità, Cefalù, Città, Forlì, Mondovì, Nardò, Perugia-Città)
- Apostrofo: "Val d Elsa" → "Val d'Elsa"
- `declare(strict_types=1)` aggiunto
- `firstOrCreate` per idempotenza
2. **`build-dist.sh`** (`post-deploy.sh` generato):
- Upgrade: aggiunto step `[6/10] Seed diocesi` dopo migrazioni (rinumerati da [1-5/9] → [1-10/10])
- Install: aggiunto step `[9/11] Seed diocesi` dopo migrazioni (rinumerato da [1-8/10] → [1-11/11])
- Usa `--force` per bypassare conferma in produzione
**File modificati**:
- `database/seeders/DiocesiSeeder.php` — riscritto con 225 nomi + fix encoding
- `build-dist.sh` — aggiunto step seed diocesi in upgrade/install
## 2026-06-23 — Fix 419: SESSION_DRIVER=database + diagnose CSRF su IP locale
**Problema**: Accesso via IP di rete locale (`http://192.168.222.174`) poteva causare 419 Page Expired. Sessione su file vulnerabile a permessi/LOCK del filesystem.
**Fix**:
1. **`SESSION_DRIVER=database`**: cambiato da `file` a `database`. La sessione su DB non soffre di:
- Permessi filesystem errati
- Lock concorrente su file
- Pulizia sessioni scadute lottery-based
2. **Migration `2026_06_23_000001_create_sessions_table.php`**: con guard `Schema::hasTable` per idempotenza cross-DB
3. **`diagnose.php`**: aggiunto test CSRF su IP locale automatico. Rileva `hostname -I` ed esegue POST login → verifica 419 su `http://<lan-ip>/login`
4. **`.env` e `.env.example`**: `SESSION_DRIVER=file``SESSION_DRIVER=database`
5. Vecchi file di sessione in `storage/framework/sessions/` eliminati
**Risultato**:
- diagnose.php: `✅ HTTP: POST login → HTTP 302 (nessun 419)`
- diagnose.php: `✅ LOCAL IP: POST login → HTTP 302 (nessun 419)`
- Sessioni persistono in tabella `sessions` (DB) invece di file
**File modificati**:
- `database/migrations/2026_06_23_000001_create_sessions_table.php` (nuovo)
- `.env` — SESSION_DRIVER=database
- `.env.example` — SESSION_DRIVER=database
- `diagnose.php` — test CSRF su IP locale
## 2026-06-23 — Email compose: firma dopo corpo + Mailing list: mittente e firma
**Obiettivo**: Spostare la select firma dopo il corpo email nella pagina di composizione email. Aggiungere mittente predefinito (`sender_account_id`) alle mailing list (salvato in creazione/edit, usato come fallback all'invio).
### Cosa è stato fatto
**Migration** (`2026_06_23_000002_add_sender_account_id_to_mailing_lists_table.php`):
- Aggiunta colonna `sender_account_id` (FK → sender_accounts, nullOnDelete) a `mailing_lists`
**Model `MailingList.php`**:
- `sender_account_id` aggiunto a `$fillable`
- `senderAccount()`: nuova relazione BelongsTo → `SenderAccount`
**Controller `MailingListController.php`**:
- `create()`: passa `$senderAccounts` (SenderAccount::active()->get()) alla view
- `edit()`: passa `$senderAccounts` alla view, eager-load `senderAccount`
- `store()`: validazione `sender_account_id` nullable|exists, salvato in create
- `update()`: validazione `sender_account_id` nullable|exists, salvato in update
**View `mailing-liste/create.blade.php`**:
- Aggiunto select per `sender_account_id` dopo il select firma
**View `mailing-liste/edit.blade.php`**:
- Aggiunto select per `sender_account_id` dopo il select firma, con `selected` se uguale a `$mailingList->sender_account_id`
**View `email/compose.blade.php`**:
- Spostato blocco firma (prima del body) → dopo il textarea body (prima della sezione allegati)
**Controller `MailingController.php`**:
- `invia()`: fallback a `$lista->senderAccount` se `mittente_id` non fornito esplicitamente (stesso pattern del firma_id fallback già esistente)
- `invioElabora()`: fallback a `mittente_id`/`firma_id` dalla mailing list se non forniti esplicitamente (solo quando una singola lista è selezionata)
**File modificati**:
- `database/migrations/2026_06_23_000002_add_sender_account_id_to_mailing_lists_table.php` (nuovo)
- `app/Models/MailingList.php` — fillable + senderAccount relazione
- `app/Http/Controllers/MailingListController.php` — sender_account in create/edit/store/update
- `app/Http/Controllers/MailingController.php` — fallback mittente/firma da mailing list
- `resources/views/email/compose.blade.php` — firma spostata dopo il body
- `resources/views/mailing-liste/create.blade.php` — select mittente aggiunto
- `resources/views/mailing-liste/edit.blade.php` — select mittente aggiunto
## 2026-06-23 — Fix document move: modal event listener + PHP empty-ids guard
**Problema**: Spostare documenti (singolo via icona cartella o mass move via toolbar) mostrava "successo" ma non spostava nulla. Il documento rimaneva nella cartella originale.
**Root cause**:
1. **JS — Bootstrap 4 event compatibility**: `addEventListener('show.bs.modal', ...)` al mass move modal usava native DOM listener, ma Bootstrap 4.6.2 dispone `show.bs.modal` come evento jQuery. Il listener non veniva mai eseguito → hidden input `massMoveIds` mai popolato → `explode(',', '')``['']``empty([''])` è `false``Documento::whereIn('id', [''])` (0 rows affected) → falso successo.
2. **PHP — No guardia su array vuoto dopo explode**: `massMove()`, `massDownload()`, `massDestroy()`, `massAssociate()`, `massTag()` in `DocumentoController.php` non filtravano valori vuoti/non-numerici da `$ids`.
**Fix**:
- `resources/views/documenti/index.blade.php:1306`: `document.getElementById('massMoveModal')?.addEventListener('show.bs.modal', ...)``$('#massMoveModal').on('show.bs.modal', ...)` (jQuery event, coerente con tutti gli altri listener nello stesso file)
- `app/Http/Controllers/DocumentoController.php`: aggiunto `array_values(array_filter($ids, fn($v) => is_numeric($v)))` in `massMove()`, `massDownload()`, `massDestroy()`, `massAssociate()`, `massTag()` — filtra stringhe vuote e non-numeriche prima di ogni `whereIn`
- `massMove()`: aggiunto controllo esplicito `count($ids)` per validare singolo documento nel flusso per-documento
**File modificati**:
- `resources/views/documenti/index.blade.php``addEventListener` → jQuery `.on()` a riga 1306
- `app/Http/Controllers/DocumentoController.php``array_filter` guard in 5 mass-action metodi
**Verifica**:
- PHP lint: OK su controller
- JS brace balance: OK (diff=0)
- Tutti gli altri listener modal nel file usano già jQuery `.on()` — il fix allinea `massMoveModal` al pattern esistente
- Nessun test automatizzato presente nel progetto (no Pest, no PHPUnit config)
## 2026-06-23 — Test + Fix migration per compatibilità SQLite + massDownload unlink fix
### Tests scritti
- **`tests/Unit/DocumentoLinkTest.php`**: 6 unit test per `Documento::isLink()` — copre tutti i valori `tipo` (link, upload, allegato, null), verifica `url` fillable, verifica link-doc senza file fields
- **`tests/Feature/DocumentoLinkTest.php`**: 14 feature test per link-doc CRUD:
- `store` con link: creazione base, validazione required url, validazione formato url, skip file, set tipo=link, visibilità individuo, visibilità gruppo
- `update`: modifica URL, preserva URL se non inviato, validazione formato
- `download`: redirect away all'URL esterno
- `preview`: vista fallback con header X-Preview-Fallback
- `destroy`: delete senza chiamate Storage
- `massDownload`: skip link-doc con errore "Nessun file disponibile"
- **Totale**: 20 test, 45 assertion, tutti ✅ passanti su SQLite `:memory:`
### Fix migration per compatibilità SQLite (test cross-DB)
3 migration non compatibili con SQLite fixate per permettere test su `:memory:`:
1. **`2026_05_12_000002_change_ruolo_to_multi.php`**:
- Aggiunto `Schema::hasColumn('gruppo_individuo', 'ruolo_id')` guard prima di `dropForeign`/`dropColumn`
- Aggiunto `dropIndex(['ruolo_id'])` prima di `dropColumn` (SQLite richiede drop index esplicito)
2. **`2024_01_01_000008_create_documenti_table.php`**:
- `$table->enum('tipologia', [...])``$table->string('tipologia', 50)` (app-level validation)
- `$table->enum('visibilita', [...])``$table->string('visibilita', 50)` (app-level validation)
- `$table->string('file_path')``$table->string('file_path')->nullable()` (necessario per link-docs)
3. **`2026_05_11_000005_add_email_attachment_to_documenti.php`**: reso no-op (colonna già string, app-level validation)
4. **`2026_05_13_072203_add_programma_locandina_to_tipologia_enum.php`**: reso no-op (colonna già string, app-level validation)
### Fix massDownload unlink edge case
- **`app/Http/Controllers/DocumentoController.php:710`**: Aggiunto `file_exists($zipPath)` guard prima di `@unlink($zipPath)` — preveniva `ErrorException` quando ZipArchive non creava file su alcuni filesystem (documenti tutti link → addedCount=0 → file zip non creato fisicamente → unlink falliva)
### Fix produzione: file_path NOT NULL su MySQL
- **Problema**: La colonna `file_path` su MySQL è `NOT NULL` (migrazione originale `$table->string('file_path')`). Inserendo un link-doc (`tipo='link'`) senza `file_path`, MySQL rifiuta: `Field 'file_path' doesn't have a default value`.
- **Fix**: Nuova migration `2026_06_23_085637_make_file_path_nullable_in_documenti.php``ALTER TABLE documenti MODIFY COLUMN file_path VARCHAR(255) NULL` su MySQL
- **Eseguita** localmente ✅ (80ms)
### build-dist.sh
- Nessuna modifica necessaria: `php artisan migrate --force` è già presente in `post-deploy.sh` sia per upgrade (`[5/10]`) che per install (`[8/11]`)
### Prossimi Passi
- ✅ Eseguire `php artisan migrate` su produzione per rendere `file_path` nullable
- Verificare in ambiente di staging: creazione link-doc (tutte le visibilità), visualizzazione grid/list, preview fallback, download (redirect), modifica URL, eliminazione, mass-download con skip link, email/mailing con link nel corpo
- Test edge case: URL malformato (validazione update), link senza url, link con cartella_id, link con visibilità individuo/gruppo
Executable
+59
View File
@@ -0,0 +1,59 @@
# Proxy Configuration — Glastree
## Nginx Proxy Manager (Custom Nginx Configuration)
Nella scheda **Advanced** del tuo proxy host:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
```
---
## `.env` (App Laravel)
```env
APP_URL=https://glastree.soon.it
APP_PROTOCOL=https
FORCE_HTTPS=true
TRUSTED_PROXIES=*
```
Dopo qualsiasi modifica:
```bash
php artisan config:clear
```
---
## Google OAuth — Authorized Redirect URIs
```
https://glastree.soon.it/auth/google/callback
```
---
## `bootstrap/app.php`
```php
$middleware->trustProxies(at: '*');
```
---
## Schema richieste
```
Cliente (HTTPS)
Nginx Proxy Manager (termina TLS, inoltra HTTP + X-Forwarded-Proto: https)
App Laravel (vede richieste come HTTPS grazie a trustProxies(at:"*"))
```
+11 -3
View File
@@ -28,16 +28,17 @@ class BackupRunCommand extends Command
{
$this->info('Avvio backup...');
$options = [];
if ($this->option('no-files')) {
$this->backupService->saveConfig(['backup_include_files' => false]);
$options['include_files'] = false;
$this->warn('Files esclusi dal backup.');
}
if ($this->option('no-env')) {
$this->backupService->saveConfig(['backup_include_env' => false]);
$options['include_env'] = false;
$this->warn('.env escluso dal backup.');
}
$result = $this->backupService->run();
$result = $this->backupService->run($options);
if ($result['success']) {
$this->info(' Backup completato con successo!');
@@ -45,6 +46,13 @@ class BackupRunCommand extends Command
$this->line('Dimensione: ' . $result['size_formatted']);
$this->line('Percorso: ' . storage_path('app/backups/' . $result['filename']));
if (!empty($result['steps'])) {
$this->line('Contenuto:');
foreach ($result['steps'] as $step) {
$this->line(' - ' . $step);
}
}
return Command::SUCCESS;
}
+1
View File
@@ -7,6 +7,7 @@ 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
{
+15 -15
View File
@@ -6,43 +6,43 @@ namespace App\Enums;
enum GoogleService: string
{
case Gmail = 'gmail';
case Email = 'email';
case Drive = 'drive';
case Calendar = 'calendar';
public function scope(): string
public function scopes(): array
{
return match ($this) {
self::Gmail => 'https://mail.google.com/',
self::Drive => 'https://www.googleapis.com/auth/drive',
self::Calendar => 'https://www.googleapis.com/auth/calendar',
self::Email => ['https://mail.google.com/'],
self::Drive => ['https://www.googleapis.com/auth/drive'],
self::Calendar => ['https://www.googleapis.com/auth/calendar'],
};
}
public function label(): string
{
return match ($this) {
self::Gmail => 'Gmail (lettura e invio email)',
self::Drive => 'Google Drive (file)',
self::Calendar => 'Google Calendar (eventi)',
self::Email => 'Gmail',
self::Drive => 'Google Drive',
self::Calendar => 'Google Calendar',
};
}
public function icon(): string
{
return match ($this) {
self::Gmail => 'fa-google',
self::Drive => 'fa-google-drive',
self::Calendar => 'fa-calendar-alt',
self::Email => 'fas fa-envelope',
self::Drive => 'fab fa-google-drive',
self::Calendar => 'fas fa-calendar-alt',
};
}
public function hexColor(): string
public function description(): string
{
return match ($this) {
self::Gmail => '#EA4335',
self::Drive => '#34A853',
self::Calendar => '#4285F4',
self::Email => 'Invia e ricevi email tramite Gmail API (alternativa a SMTP/IMAP con password)',
self::Drive => 'Accedi ai file su Google Drive per documenti e allegati',
self::Calendar => 'Sincronizza eventi con Google Calendar',
};
}
}
@@ -7,6 +7,7 @@ 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;
@@ -33,8 +34,9 @@ class BackupController extends Controller
$result = $this->backupService->run();
if ($result['success']) {
$steps = !empty($result['steps']) ? ' — ' . implode(', ', $result['steps']) : '';
return redirect()->route('admin.backup.index')
->with('success', 'Backup completato: ' . ($result['filename'] ?? '') . ' (' . ($result['size_formatted'] ?? '') . ')');
->with('success', 'Backup completato: ' . ($result['filename'] ?? '') . ' (' . ($result['size_formatted'] ?? '') . ')' . $steps);
}
return redirect()->route('admin.backup.index')
@@ -108,4 +110,10 @@ class BackupController extends Controller
->with('success', 'Configurazione backup automatico salvata.');
}
public function downloadMigrationScript(): JsonResponse
{
$script = view('admin.backup.migration-script')->render();
return response()->json(['script' => $script]);
}
}
@@ -2,15 +2,20 @@
namespace App\Http\Controllers\Admin;
use App\Enums\GoogleService;
use App\Http\Controllers\Controller;
use App\Models\EmailAttachment;
use App\Models\EmailFolder;
use App\Models\EmailSetting;
use App\Models\Firma;
use App\Models\SenderAccount;
use App\Services\GoogleOAuthService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
class EmailSettingsController extends Controller
{
@@ -19,7 +24,9 @@ class EmailSettingsController extends Controller
$this->authorizeWrite('settings');
$settings = EmailSetting::first() ?? new EmailSetting();
$senderAccounts = SenderAccount::orderBy('email_address')->get();
return view('admin.email-settings.index', compact('settings', 'senderAccounts'));
$googleStatus = app(GoogleOAuthService::class)->getConnectionStatus();
$firme = $settings->exists ? $settings->firme : collect();
return view('admin.email-settings.index', compact('settings', 'senderAccounts', 'googleStatus', 'firme'));
}
public function save(Request $request)
@@ -38,11 +45,15 @@ class EmailSettingsController extends Controller
'smtp_password' => 'nullable|string',
'email_address' => 'required|email',
'email_name' => 'nullable|string|max:255',
'from_email' => 'nullable|email|max:255',
'from_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',
'auth_method' => 'nullable|in:password,oauth',
'google_oauth_connection_id' => 'nullable|integer|exists:google_oauth_connections,id',
]);
if (!empty($validated['imap_password'])) {
@@ -61,29 +72,17 @@ class EmailSettingsController extends Controller
$validated
);
// Verifica che i dati siano stati salvati
$verify = EmailSetting::find($saved->id);
if ($verify &&
$allGood = $verify &&
$verify->imap_host === $validated['imap_host'] &&
$verify->email_address === $validated['email_address']) {
$verify->email_address === $validated['email_address'];
$signatureMatch = !array_key_exists('signature', $validated) || $verify->signature === $validated['signature'];
$signatureEnabledMatch = !array_key_exists('signature_enabled', $validated) || (bool)$verify->signature_enabled === (bool)$validated['signature_enabled'];
if (!$signatureMatch) {
\Illuminate\Support\Facades\Log::warning('Signature mismatch after save', [
'expected' => $validated['signature'] ?? null,
'actual' => $verify->signature,
]);
}
if (!$signatureEnabledMatch) {
\Illuminate\Support\Facades\Log::warning('Signature enabled mismatch after save', [
'expected' => $validated['signature_enabled'] ?? null,
'actual' => $verify->signature_enabled,
]);
}
$allGood = $allGood &&
(!array_key_exists('signature', $validated) || $verify->signature === $validated['signature']) &&
(!array_key_exists('signature_enabled', $validated) || (bool)$verify->signature_enabled === (bool)$validated['signature_enabled']);
if ($allGood) {
return back()->with('success', 'Impostazioni email salvate e verificate nel database.');
} else {
return back()->with('error', 'Errore nel salvataggio: i dati non sono stati salvati correttamente.');
@@ -99,7 +98,6 @@ class EmailSettingsController extends Controller
return response()->json(['success' => false, 'message' => 'Configurazione non trovata.']);
}
// Verifica che i dati siano salvati
if (empty($settings->imap_host) || empty($settings->email_address)) {
return response()->json(['success' => false, 'message' => 'Prima salva le impostazioni.']);
}
@@ -129,60 +127,27 @@ class EmailSettingsController extends Controller
return response()->json(['success' => false, 'message' => 'Configurazione non trovata.']);
}
if (empty($settings->smtp_host)) {
$settings->smtp_host = 'smtp.gmail.com';
$settings->smtp_port = 587;
$settings->smtp_encryption = 'tls';
}
try {
$smtpUsername = $settings->smtp_username;
$smtpPassword = $settings->smtp_password;
if (empty($smtpUsername)) {
$smtpUsername = $settings->email_address;
}
if (empty($smtpPassword)) {
$smtpPassword = $settings->getDecryptedPassword();
} else {
$smtpPassword = Crypt::decryptString($smtpPassword);
$mailer = $settings->getSmtpMailer();
if ($mailer === null) {
return response()->json(['success' => false, 'message' => 'Impossibile creare il mailer SMTP. Verifica la configurazione.']);
}
$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
);
$smtpUser = $settings->smtp_username ?: $settings->email_address;
$fromName = $settings->getEffectiveFromName();
$replyToEmail = $settings->from_email ?: $smtpUser;
$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)
$email = (new Email())
->from(Address::create($smtpUser, $fromName))
->to($request->email)
->subject('Test Configurazione SMTP - Glastree')
->text('Test email da Glastree - Configurazione SMTP funziona!');
if ($replyToEmail !== $smtpUser) {
$email->replyTo(Address::create($replyToEmail, $fromName));
}
$mailer->send($email);
\Illuminate\Support\Facades\Log::info('Test SMTP ok', ['to' => $request->email, 'from' => $settings->email_address]);
@@ -227,6 +192,8 @@ class EmailSettingsController extends Controller
'verify_email' => 'nullable|email|max:255',
'note' => 'nullable|string|max:500',
'is_active' => 'boolean',
'auth_method' => 'nullable|in:password,oauth',
'google_oauth_connection_id' => 'nullable|integer|exists:google_oauth_connections,id',
]);
if (!empty($data['smtp_password'])) {
@@ -257,6 +224,8 @@ class EmailSettingsController extends Controller
'verify_email' => 'nullable|email|max:255',
'note' => 'nullable|string|max:500',
'is_active' => 'boolean',
'auth_method' => 'nullable|in:password,oauth',
'google_oauth_connection_id' => 'nullable|integer|exists:google_oauth_connections,id',
]);
if (!empty($data['smtp_password'])) {
@@ -326,4 +295,66 @@ class EmailSettingsController extends Controller
return response()->json(['success' => false, 'message' => 'Errore invio: ' . $e->getMessage()]);
}
}
}
public function firmaStore(Request $request)
{
$this->authorizeWrite('settings');
$data = $request->validate([
'nome' => 'required|string|max:255',
'contenuto_html' => 'nullable|string',
'is_default' => 'boolean',
]);
$settings = EmailSetting::getActive();
if (!$settings) {
return redirect('/impostazioni/email')->with('error', 'Account email non configurato.');
}
$firma = $settings->firme()->create($data);
if ($data['is_default'] ?? false) {
$settings->firme()->where('id', '!=', $firma->id)->update(['is_default' => false]);
}
return redirect('/impostazioni/email#firme')->with('success', 'Firma "' . $firma->nome . '" creata.');
}
public function firmaUpdate(Request $request, int $id)
{
$this->authorizeWrite('settings');
$data = $request->validate([
'nome' => 'required|string|max:255',
'contenuto_html' => 'nullable|string',
'is_default' => 'boolean',
]);
$firma = Firma::findOrFail($id);
$firma->update($data);
if ($data['is_default'] ?? false) {
Firma::where('email_setting_id', $firma->email_setting_id)
->where('id', '!=', $firma->id)
->update(['is_default' => false]);
}
return redirect('/impostazioni/email#firme')->with('success', 'Firma "' . $firma->nome . '" aggiornata.');
}
public function firmaSetDefault(int $id)
{
$this->authorizeWrite('settings');
$firma = Firma::findOrFail($id);
Firma::where('email_setting_id', $firma->email_setting_id)->update(['is_default' => false]);
$firma->update(['is_default' => true]);
return redirect('/impostazioni/email#firme')->with('success', 'Firma "' . $firma->nome . '" impostata come predefinita.');
}
public function firmaDestroy(int $id)
{
$this->authorizeWrite('settings');
$firma = Firma::findOrFail($id);
$nome = $firma->nome;
$firma->delete();
return redirect('/impostazioni/email#firme')->with('success', 'Firma "' . $nome . '" eliminata.');
}
}
@@ -4,12 +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
{
@@ -64,7 +64,7 @@ class CalendarioConnessioneController extends Controller
$connessione->is_active = $request->boolean('is_active', true);
if (!empty($data['config'])) {
$connessione->encryptAndSetConfig($data['config']);
$connessione->encryptAndSetConfig($data['config'], $connessione->config ?? []);
}
$connessione->save();
+144 -34
View File
@@ -56,7 +56,7 @@ class DocumentoController extends Controller
} elseif ($folderId) {
$query->where('cartella_id', $folderId);
} else {
$query->whereNull('repository_id');
$query->whereNull('repository_id')->whereNull('cartella_id');
}
$documenti = $query->paginate(20);
@@ -134,6 +134,16 @@ class DocumentoController extends Controller
));
}
public function create()
{
$this->authorizeWrite('documenti');
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
$gruppi = Gruppo::orderBy('nome')->get();
$eventi = Evento::orderBy('nome_evento')->get();
$tipologie = TipologiaDocumento::attive();
return view('documenti.create', compact('individui', 'gruppi', 'eventi', 'tipologie'));
}
public function edit($documento)
{
$this->authorizeWrite('documenti');
@@ -191,33 +201,43 @@ class DocumentoController extends Controller
$tipologieValidi = TipologiaDocumento::opzioni();
$tipologieRule = 'in:' . implode(',', $tipologieValidi);
$data = $request->validate([
$rules = [
'nome_file' => 'required|string|max:255',
'tipologia' => 'required|' . $tipologieRule,
'visibilita' => 'nullable|string',
'contesto_tipo' => 'nullable|in:individuo,gruppo,evento,mailing',
'visibilita_target_id' => 'nullable|integer',
'visibilita_target_type' => 'nullable|string',
'note' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
];
$data['visibilita'] = $request->contesto_tipo ?: 'pubblico';
if ($documento->isLink()) {
$rules['url'] = 'nullable|url|max:2048';
}
if ($request->contesto_tipo) {
$data = $request->validate($rules);
$contestoTipo = $data['contesto_tipo'] ?? null;
$data['visibilita'] = $contestoTipo ?: 'pubblico';
if ($contestoTipo) {
$typeMap = [
'individuo' => Individuo::class,
'gruppo' => Gruppo::class,
'evento' => Evento::class,
'mailing' => \App\Models\MailingList::class,
];
$data['visibilita_target_type'] = $typeMap[$request->contesto_tipo] ?? null;
$data['visibilita_target_type'] = $typeMap[$contestoTipo] ?? null;
} else {
$data['visibilita'] = 'pubblico';
$data['visibilita_target_id'] = null;
$data['visibilita_target_type'] = null;
}
if ($documento->isLink()) {
$data['url'] = $data['url'] ?? $documento->url;
}
$documento->update($data);
if ($request->has('tags')) {
@@ -226,22 +246,69 @@ class DocumentoController extends Controller
$documento->tags()->detach();
}
return redirect('/documenti')->with('success', 'Documento aggiornato.');
$redirect = $request->input('folder_id') ? '/documenti?folder_id=' . $request->input('folder_id') : '/documenti';
return redirect($redirect)->with('success', 'Documento aggiornato.');
}
public function store(Request $request)
{
$this->authorizeWrite('documenti');
$data = $request->validate([
$tipologieValidi = TipologiaDocumento::opzioni();
$tipologieRule = 'in:' . implode(',', $tipologieValidi);
$isLink = $request->input('tipo_caricamento') === 'link';
$rules = [
'nome_file' => 'required|string|max:255',
'tipologia' => 'required|in:avatar,galleria,documento,statuto,altro',
'file' => 'required|file|max:10240',
'tipologia' => 'required|' . $tipologieRule,
'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',
]);
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'tipo_caricamento' => 'required|in:file,link',
'url' => 'required_if:tipo_caricamento,link|nullable|url|max:2048',
];
if (!$isLink) {
$rules['file'] = 'required|file|max:10240';
}
$data = $request->validate($rules);
if ($isLink) {
$linkVisibilitaTargetType = null;
if ($data['visibilita'] === 'individuo' && $data['visibilita_target_id']) {
$linkVisibilitaTargetType = Individuo::class;
} elseif ($data['visibilita'] === 'gruppo' && $data['visibilita_target_id']) {
$linkVisibilitaTargetType = Gruppo::class;
}
$doc = Documento::create([
'nome_file' => $data['nome_file'],
'url' => $data['url'],
'tipo' => 'link',
'tipologia' => $data['tipologia'],
'visibilita' => $data['visibilita'],
'visibilita_target_id' => $data['visibilita_target_id'] ?? null,
'visibilita_target_type' => $linkVisibilitaTargetType,
'user_id' => auth()->id(),
'cartella_id' => $data['cartella_id'] ?? null,
'repository_id' => null,
]);
if ($doc && $request->has('tags')) {
$doc->tags()->sync($request->tags);
}
$redirect = $request->_redirect ?? url()->previous();
if ($request->filled('folder_id')) {
$redirect = '/documenti?folder_id=' . $request->input('folder_id');
}
return redirect($redirect)->with('success', 'Documento (link) creato.');
}
$repositoryId = $data['repository_id'] ?? null;
@@ -274,7 +341,7 @@ class DocumentoController extends Controller
$visibilitaTargetType = Gruppo::class;
}
Documento::create([
$doc = Documento::create([
'nome_file' => $data['nome_file'],
'file_path' => $filePath,
'storage_disk' => $disk ?? null,
@@ -290,7 +357,13 @@ class DocumentoController extends Controller
'repository_id' => $repositoryId,
]);
if ($doc && $request->has('tags')) {
$doc->tags()->sync($request->tags);
}
$redirect = $request->_redirect ?? url()->previous();
if ($request->filled('folder_id')) {
$redirect = '/documenti?folder_id=' . $request->input('folder_id');
}
return redirect($redirect)->with('success', 'Documento caricato.');
}
@@ -299,6 +372,10 @@ class DocumentoController extends Controller
$this->authorizeRead('documenti');
$documento = Documento::findOrFail($documento);
if ($documento->isLink()) {
return redirect()->away($documento->url);
}
$extension = pathinfo($documento->file_path, PATHINFO_EXTENSION);
$filename = $extension ? $documento->nome_file . '.' . $extension : $documento->nome_file;
@@ -344,6 +421,12 @@ class DocumentoController extends Controller
$this->authorizeRead('documenti');
$documento = Documento::findOrFail($documento);
if ($documento->isLink()) {
return response()->view('documenti.preview-fallback', [
'documento' => $documento,
])->header('X-Preview-Fallback', 'true');
}
$previewableMimeTypes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
'application/pdf',
@@ -394,7 +477,8 @@ class DocumentoController extends Controller
$this->authorizeDelete('documenti');
$documento = Documento::findOrFail($documento);
if ($documento->repository_id) {
if (!$documento->isLink()) {
if ($documento->repository_id) {
$repo = StorageRepository::find($documento->repository_id);
if ($repo) {
$filesystem = $this->repoService->buildFilesystem($repo);
@@ -402,10 +486,11 @@ class DocumentoController extends Controller
$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);
} else {
$disk = $this->resolveStorageDisk($documento);
if (Storage::disk($disk)->exists($documento->file_path)) {
Storage::disk($disk)->delete($documento->file_path);
}
}
}
@@ -484,9 +569,10 @@ class DocumentoController extends Controller
{
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
$ids = array_values(array_filter($ids, fn($v) => is_numeric($v)));
if (empty($ids)) {
return response()->json(['linked_count' => 0, 'links' => []]);
return back()->with('error', 'Nessun documento selezionato.');
}
$documenti = Documento::whereIn('id', $ids)->get();
@@ -562,6 +648,7 @@ class DocumentoController extends Controller
$this->authorizeRead('documenti');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
$ids = array_values(array_filter($ids, fn($v) => is_numeric($v)));
if (empty($ids)) {
return back()->with('error', 'Nessun documento selezionato.');
@@ -580,8 +667,14 @@ class DocumentoController extends Controller
return back()->with('error', 'Impossibile creare l\'archivio ZIP.');
}
$skippedLinks = 0;
$addedCount = 0;
foreach ($documenti as $documento) {
if ($documento->isLink()) {
$skippedLinks++;
continue;
}
$extension = pathinfo($documento->file_path, PATHINFO_EXTENSION);
$filename = $extension ? $documento->nome_file . '.' . $extension : $documento->nome_file;
@@ -614,11 +707,23 @@ class DocumentoController extends Controller
$zip->close();
if ($addedCount === 0) {
unlink($zipPath);
return back()->with('error', 'Nessun file disponibile per il download.');
if (file_exists($zipPath)) {
@unlink($zipPath);
}
$message = 'Nessun file disponibile per il download.';
if ($skippedLinks > 0) {
$message .= " ({$skippedLinks} link esterni saltati)";
}
return back()->with('error', $message);
}
return response()->download($zipPath, 'documenti.zip')->deleteFileAfterSend(true);
$message = $addedCount . ' file scaricati.';
if ($skippedLinks > 0) {
$message .= " {$skippedLinks} link esterni saltati.";
}
return response()->download($zipPath, 'documenti.zip')->deleteFileAfterSend(true)
->with('success', $message);
}
public function massDestroy(Request $request)
@@ -626,6 +731,7 @@ class DocumentoController extends Controller
$this->authorizeDelete('documenti');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
$ids = array_values(array_filter($ids, fn($v) => is_numeric($v)));
if (empty($ids)) {
return back()->with('error', 'Nessun documento selezionato.');
@@ -634,18 +740,20 @@ class DocumentoController extends Controller
$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);
if (!$documento->isLink()) {
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);
}
}
} else {
$disk = $this->resolveStorageDisk($documento);
if (Storage::disk($disk)->exists($documento->file_path)) {
Storage::disk($disk)->delete($documento->file_path);
}
}
$documento->delete();
@@ -721,6 +829,7 @@ class DocumentoController extends Controller
$this->authorizeWrite('documenti');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
$ids = array_values(array_filter($ids, fn($v) => is_numeric($v)));
if (empty($ids)) {
return back()->with('error', 'Nessun documento selezionato.');
@@ -752,6 +861,7 @@ class DocumentoController extends Controller
$this->authorizeWrite('documenti');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
$ids = array_values(array_filter($ids, fn($v) => is_numeric($v)));
if (empty($ids)) {
return back()->with('error', 'Nessun documento selezionato.');
+77 -193
View File
@@ -11,7 +11,9 @@ use App\Models\MailingList;
use App\Models\Gruppo;
use App\Models\Individuo;
use App\Models\Documento;
use App\Models\Firma;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -95,6 +97,9 @@ class EmailController extends Controller
$documenti = Documento::orderBy('nome_file')->get();
$settings = EmailSetting::getActive();
$firme = $settings?->firme ?? collect();
$prefill = [];
if ($request->reply_to) {
$original = EmailMessage::find($request->reply_to);
@@ -112,7 +117,7 @@ class EmailController extends Controller
}
}
return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'documenti', 'prefill', 'senderAccounts'));
return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'documenti', 'prefill', 'senderAccounts', 'firme'));
}
public function send(Request $request)
@@ -127,6 +132,7 @@ class EmailController extends Controller
$rules['to'] = 'required';
}
$rules['firma_id'] = 'nullable|integer|exists:firme,id';
$validated = $request->validate($rules);
$recipients = $this->resolveRecipients($request);
@@ -156,12 +162,18 @@ class EmailController extends Controller
$imapSuccess = false;
$imapError = null;
$attachments = $this->processAttachments($request);
$attachmentResult = $this->processAttachments($request);
$attachmentPaths = $attachmentResult['paths'];
$linkDocs = $attachmentResult['links'];
$firmaId = $request->firma_id;
$body = $this->appendLinksToBody($validated['body'], $linkDocs);
foreach ($recipients as $recipient) {
try {
$bodyWithSig = $this->appendSignature($validated['body'], $settings->getSignature(), $settings->signature_enabled ?? false);
$this->sendViaImap($settings, $recipient, $validated['subject'], $bodyWithSig, $request->cc, $attachments);
$bodyWithSig = $this->appendSignature($body, $firmaId, $settings);
$this->sendViaImap($settings, $recipient, $validated['subject'], $bodyWithSig, $request->cc, $attachmentPaths);
$imapSuccess = true;
} catch (\Exception $e) {
$imapError = $e->getMessage();
@@ -170,7 +182,7 @@ class EmailController extends Controller
}
try {
$bodyWithSig = $this->appendSignature($validated['body'], $settings->getSignature(), $settings->signature_enabled ?? false);
$bodyWithSig = $this->appendSignature($body, $firmaId, $settings);
$this->storeSentMessage($settings, $recipients, $validated['subject'], $bodyWithSig, $request->cc);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Store sent message error', ['error' => $e->getMessage()]);
@@ -187,13 +199,20 @@ class EmailController extends Controller
private function sendWithSender(Request $request, array $validated, array $recipients)
{
$sender = SenderAccount::findOrFail($request->mittente_id);
$attachmentPaths = $this->resolveAttachmentPaths($request);
$attachmentResult = $this->resolveAttachmentPaths($request);
$attachmentPaths = $attachmentResult['paths'];
$linkDocs = $attachmentResult['links'];
$settings = EmailSetting::getActive();
$body = $this->appendLinksToBody($validated['body'], $linkDocs);
$body = $this->appendSignature($body, $request->firma_id, $settings);
$ok = 0;
$errors = [];
foreach ($recipients as $recipient) {
try {
$sender->sendEmail($recipient, $validated['subject'], $validated['body'], $attachmentPaths);
$sender->sendEmail($recipient, $validated['subject'], $body, $attachmentPaths);
$ok++;
} catch (\Exception $e) {
$errors[] = $recipient . ': ' . $e->getMessage();
@@ -236,8 +255,8 @@ class EmailController extends Controller
'body_text' => $request->body,
'to_email' => $request->to,
'is_draft' => true,
'from_email' => $settings->email_address ?? '',
'from_name' => $settings->email_name ?? '',
'from_email' => $settings->getEffectiveFromAddress(),
'from_name' => $settings->getEffectiveFromName(),
]);
return response()->json(['success' => true, 'draft_id' => $message->id]);
@@ -401,10 +420,6 @@ class EmailController extends Controller
$this->ensureFoldersExist();
if ($settings->isOauth()) {
return $this->syncEmailsViaOAuth($settings);
}
$mailbox = $this->connectImap($settings);
\Illuminate\Support\Facades\Log::info('IMAP connection established', ['host' => $settings->imap_host]);
@@ -499,12 +514,20 @@ class EmailController extends Controller
return array_filter(array_unique($recipients));
}
private function appendSignature(string $body, ?string $signature, bool $enabled): string
private function appendSignature(string $body, int|string|null $firmaId = null, ?EmailSetting $settings = null): string
{
if (empty($signature) || !$enabled) {
return $body;
if ($firmaId) {
$firma = Firma::find($firmaId);
if ($firma && $firma->contenuto_html) {
return $body . "\n\n---\n" . $firma->contenuto_html;
}
}
return $body . "\n\n---\n" . $signature;
if ($settings && $settings->signature && ($settings->signature_enabled ?? false)) {
return $body . "\n\n---\n" . $settings->signature;
}
return $body;
}
private function sendViaImap($settings, $to, $subject, $body, $cc = null, array $attachmentPaths = [])
@@ -526,33 +549,26 @@ class EmailController extends Controller
$encryption
);
if ($settings->isOauth()) {
$dsn .= '&auth_mode=xoauth2';
}
try {
$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)) {
$fromName = 'Glastree';
}
}
$fromAddress = \Symfony\Component\Mime\Address::create($settings->email_address, $fromName);
$fromAddress = \Symfony\Component\Mime\Address::create(
$settings->getEffectiveFromAddress(),
$settings->getEffectiveFromName()
);
$email = (new \Symfony\Component\Mime\Email())
->from($fromAddress)
->to($to)
->subject($subject)
->text($body);
$replyTo = $settings->getEffectiveReplyTo();
if ($replyTo) {
$email->replyTo($replyTo);
}
if ($cc) {
$email->cc($cc);
}
@@ -574,6 +590,7 @@ class EmailController extends Controller
private function processAttachments(Request $request): array
{
$paths = [];
$links = [];
if ($request->hasFile('allegati')) {
foreach ($request->file('allegati') as $file) {
@@ -588,18 +605,21 @@ class EmailController extends Controller
$ids = is_array($documentIds) ? $documentIds : explode(',', $documentIds);
$docs = Documento::whereIn('id', $ids)->get();
foreach ($docs as $doc) {
if ($doc->file_path && Storage::exists($doc->file_path)) {
if ($doc->isLink()) {
$links[] = ['nome' => $doc->nome_file, 'url' => $doc->url];
} elseif ($doc->file_path && Storage::exists($doc->file_path)) {
$paths[] = Storage::path($doc->file_path);
}
}
}
return $paths;
return ['paths' => $paths, 'links' => $links];
}
private function resolveAttachmentPaths(Request $request): array
{
$paths = [];
$links = [];
if ($request->hasFile('allegati')) {
foreach ($request->file('allegati') as $file) {
@@ -614,26 +634,42 @@ class EmailController extends Controller
$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)) {
if ($doc->isLink()) {
$links[] = ['nome' => $doc->nome_file, 'url' => $doc->url];
} elseif ($doc->file_path && \Illuminate\Support\Facades\Storage::exists($doc->file_path)) {
$paths[] = \Illuminate\Support\Facades\Storage::path($doc->file_path);
}
}
}
return $paths;
return ['paths' => $paths, 'links' => $links];
}
private function appendLinksToBody(string $body, array $linkDocs): string
{
if (empty($linkDocs)) {
return $body;
}
$linksText = "\n\n---\nDocumenti collegati:\n";
foreach ($linkDocs as $link) {
$linksText .= "- {$link['nome']}: {$link['url']}\n";
}
return $body . $linksText;
}
private function storeSentMessage($settings, $recipients, $subject, $body, $cc = null)
{
$sentFolder = EmailFolder::where('type', 'sent')->first();
$bodyWithSignature = $this->appendSignature($body, $settings->getSignature(), $settings->signature_enabled ?? false);
$bodyWithSignature = $body;
$message = EmailMessage::create([
'email_folder_id' => $sentFolder->id,
'subject' => $subject,
'body_text' => $bodyWithSignature,
'from_email' => $settings->email_address,
'from_name' => $settings->email_name,
'from_email' => $settings->getEffectiveFromAddress(),
'from_name' => $settings->getEffectiveFromName(),
'to_email' => implode(', ', $recipients),
'cc' => $cc,
'is_sent' => true,
@@ -650,10 +686,6 @@ class EmailController extends Controller
throw new \Exception('Configurazione IMAP non valida');
}
if ($settings->isOauth()) {
return $this->connectOAuthImap($settings);
}
$encryption = match ($settings->imap_encryption) {
'ssl' => 'ssl',
'tls' => 'tls',
@@ -670,27 +702,6 @@ class EmailController extends Controller
]);
}
private function connectOAuthImap($settings): \Webklex\PHPIMAP\Client
{
$token = $settings->getOAuthAccessToken();
if (!$token) {
throw new \Exception('Impossibile ottenere il token OAuth per IMAP');
}
$client = new \Webklex\PHPIMAP\Client([
'host' => $settings->imap_host,
'port' => $settings->imap_port ?? 993,
'encryption' => $settings->imap_encryption ?? 'ssl',
'validate_cert' => false,
'username' => $settings->imap_username,
'password' => $token,
'authentication' => 'oauth',
]);
$client->connect();
return $client;
}
private function processImapMessage($imapMessage, $imapFolder)
{
$folderType = match ($imapFolder) {
@@ -749,133 +760,6 @@ class EmailController extends Controller
}
}
private function syncEmailsViaOAuth($settings): bool
{
$this->ensureFoldersExist();
$client = $this->connectOAuthImap($settings);
$folderMappings = [
'INBOX' => 'inbox',
'Sent' => 'sent',
'[Gmail]/Sent Mail' => 'sent',
'Sent Mail' => 'sent',
'Drafts' => 'drafts',
'[Gmail]/Drafts' => 'drafts',
'Trash' => 'trash',
'[Gmail]/Trash' => 'trash',
];
$totalNew = 0;
foreach (array_keys($folderMappings) as $imapFolderName) {
try {
$folder = $client->getFolder($imapFolderName);
if (!$folder) {
continue;
}
$messages = $folder->messages()
->setFetchFlags(true)
->setFetchBody(true)
->limit(100)
->get();
foreach ($messages as $imapMessage) {
$messageId = $imapMessage->getMessageId() ?? uniqid('msg_');
$existing = EmailMessage::where('message_id', $messageId)->first();
if ($existing) {
continue;
}
$this->processWebklexMessage($imapMessage, $imapFolderName);
$totalNew++;
}
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Error processing OAuth IMAP folder', ['folder' => $imapFolderName, 'error' => $e->getMessage()]);
continue;
}
}
$client->disconnect();
\Illuminate\Support\Facades\Log::info('OAuth email sync completed', ['new' => $totalNew]);
return true;
}
private function processWebklexMessage($imapMessage, $imapFolder)
{
$folderType = match ($imapFolder) {
'INBOX', 'inbox' => 'inbox',
'Sent', 'sent', '[Gmail]/Sent Mail' => 'sent',
'Drafts', 'drafts', '[Gmail]/Drafts' => 'drafts',
'Trash', 'trash', '[Gmail]/Trash' => 'trash',
'Archive', 'archive', '[Gmail]/All Mail' => 'archive',
'Starred', 'starred', '[Gmail]/Starred' => 'starred',
default => 'inbox',
};
$folder = EmailFolder::where('type', $folderType)->first();
if (!$folder) return;
$messageId = $imapMessage->getMessageId() ?? uniqid('msg_');
$existing = EmailMessage::where('message_id', $messageId)->first();
if ($existing) return;
$from = $imapMessage->getFrom();
$to = $imapMessage->getTo();
$fromName = '';
$fromEmail = '';
if ($from && $from->first()) {
$fromName = $from->first()->personal ?? '';
$fromEmail = ($from->first()->mailbox ?? '') . '@' . ($from->first()->host ?? '');
}
$toEmail = '';
if ($to && $to->first()) {
$toEmail = ($to->first()->mailbox ?? '') . '@' . ($to->first()->host ?? '');
}
$message = EmailMessage::create([
'email_folder_id' => $folder->id,
'message_id' => $messageId,
'subject' => mb_substr($imapMessage->getSubject() ?? '(senza oggetto)', 0, 255),
'from_name' => mb_substr($fromName, 0, 255),
'from_email' => mb_substr($fromEmail, 0, 255),
'to_email' => mb_substr($toEmail, 0, 255),
'body_text' => $imapMessage->getTextBody() ?? '',
'body_html' => $imapMessage->getHTMLBody() ?? '',
'is_read' => !($imapMessage->getFlags()['UNSEEN'] ?? false),
'is_starred' => !empty($imapMessage->getFlags()['FLAGGED']),
'is_sent' => $folderType === 'sent',
'received_at' => $imapMessage->getDate() ?? now(),
'imap_uid' => $imapMessage->getUid() ?? null,
]);
$attachments = $imapMessage->getAttachments();
if ($attachments && count($attachments) > 0) {
foreach ($attachments as $attachment) {
try {
$filename = $attachment->getName() ?? 'attachment';
$contents = $attachment->getContent();
$path = 'email-attachments/' . \Illuminate\Support\Str::uuid() . '_' . $filename;
\Illuminate\Support\Facades\Storage::put($path, $contents);
EmailAttachment::create([
'email_message_id' => $message->id,
'filename' => $filename,
'mime_type' => $attachment->getMimeType() ?? 'application/octet-stream',
'size' => strlen($contents),
'file_path' => $path,
]);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Error saving OAuth attachment', ['error' => $e->getMessage()]);
}
}
}
}
private function ensureFoldersExist()
{
$systemFolders = EmailFolder::getSystemFolders();
+37
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Evento;
@@ -630,4 +632,39 @@ class EventoController extends Controller
return redirect('/eventi')->with('success', $message);
}
public function massTag(Request $request)
{
$this->authorizeWrite('eventi');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return back()->with('error', 'Nessun evento selezionato.');
}
$data = $request->validate([
'tags' => 'required|array',
'tags.*' => 'exists:tags,id',
'mode' => 'required|in:assign,remove',
]);
$tagIds = $data['tags'];
$mode = $data['mode'];
$count = 0;
Evento::whereIn('id', $ids)->chunk(100, function ($eventi) use ($tagIds, $mode, &$count) {
foreach ($eventi as $evento) {
if ($mode === 'assign') {
$evento->tags()->syncWithoutDetaching($tagIds);
} else {
$evento->tags()->detach($tagIds);
}
$count++;
}
});
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return back()->with('success', "Tag $actionLabel per $count eventi.");
}
}
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Evento;
+79 -30
View File
@@ -5,64 +5,113 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Enums\GoogleService;
use App\Models\GoogleOAuthConnection;
use App\Services\GoogleOAuthService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
class GoogleOAuthController extends Controller
{
public function __construct(
private readonly GoogleOAuthService $oauthService,
private readonly GoogleOAuthService $googleOAuthService,
) {}
public function connect(GoogleService $service): RedirectResponse
public function redirect(string $service): RedirectResponse
{
$url = $this->oauthService->getAuthorizationUrl($service);
return redirect()->away($url);
try {
$serviceEnum = GoogleService::from($service);
} catch (\ValueError $e) {
return redirect()->back()->withErrors(['error' => 'Servizio Google non valido: ' . $service]);
}
$authUrl = $this->googleOAuthService->getAuthUrl($service);
return redirect()->away($authUrl);
}
public function callback(Request $request): RedirectResponse
{
$request->validate([
'code' => 'required|string',
'state' => 'required|string|in:' . implode(',', array_column(GoogleService::cases(), 'value')),
]);
$error = $request->input('error');
if ($error) {
$message = match ($error) {
'access_denied' => 'Accesso negato. Autorizzazione non concessa.',
default => 'Errore Google: ' . $error,
};
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => $message]);
}
$service = GoogleService::from($request->state);
$code = $request->input('code');
if (!$code) {
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => 'Nessun codice di autorizzazione ricevuto']);
}
$state = $request->input('state', 'email');
try {
$service = GoogleService::from($state);
} catch (\ValueError $e) {
$service = GoogleService::Email;
}
try {
$this->oauthService->handleCallback($request->code, $service);
return redirect()->route('impostazioni.index', ['#google-services'])
->with('success', "Google {$service->label()} connected successfully.");
$connection = $this->googleOAuthService->handleCallback($code, $service);
$message = match ($service) {
GoogleService::Email => 'Account email Gmail connesso con successo!',
GoogleService::Drive => 'Account Google Drive connesso con successo!',
GoogleService::Calendar => 'Account Google Calendar connesso con successo!',
};
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->with('success', $message);
} catch (\Exception $e) {
return redirect()->route('impostazioni.index', ['#google-services'])
->with('error', 'Google OAuth error: ' . $e->getMessage());
Log::error('Google OAuth callback error', [
'error' => $e->getMessage(),
'service' => $service->value,
]);
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => 'Errore durante la connessione: ' . $e->getMessage()]);
}
}
public function revoke(GoogleService $service): RedirectResponse
public function revoke(GoogleOAuthConnection $connection): RedirectResponse
{
if ($connection->user_id !== auth()->id()) {
abort(403);
}
try {
$this->oauthService->revoke($service);
return redirect()->route('impostazioni.index', ['#google-services'])
->with('success', "Google {$service->label()} revoked successfully.");
$service = GoogleService::from($connection->service);
$message = match ($service) {
GoogleService::Email => 'Account email Gmail disconnesso.',
GoogleService::Drive => 'Account Google Drive disconnesso.',
GoogleService::Calendar => 'Account Google Calendar disconnesso.',
};
$this->googleOAuthService->revoke($connection);
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->with('success', $message);
} catch (\Exception $e) {
return redirect()->route('impostazioni.index', ['#google-services'])
->with('error', 'Revocation error: ' . $e->getMessage());
Log::error('Google OAuth revoke error', [
'error' => $e->getMessage(),
'connection_id' => $connection->id,
]);
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => 'Errore durante la disconnessione: ' . $e->getMessage()]);
}
}
public function status(): View
public function status(): \Illuminate\Http\JsonResponse
{
$services = [];
foreach (GoogleService::cases() as $service) {
$services[] = [
'service' => $service,
'authorized' => $this->oauthService->isAuthorized($service),
];
}
$status = $this->googleOAuthService->getConnectionStatus();
return view('google-oauth.status', ['services' => $services]);
return response()->json($status);
}
}
}
+70 -12
View File
@@ -11,6 +11,7 @@ use App\Models\Tag;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class GruppoController extends Controller
@@ -36,6 +37,7 @@ class GruppoController extends Controller
$allColumnDefs = [
['key' => 'nome', 'label' => 'Nome'],
['key' => 'anno_fondazione', 'label' => 'Anno Fondazione'],
['key' => 'descrizione', 'label' => 'Descrizione'],
['key' => 'diocesi', 'label' => 'Diocesi'],
['key' => 'livello', 'label' => 'Livello'],
@@ -65,7 +67,7 @@ class GruppoController extends Controller
$entityType = 'gruppi';
$columnWidths = $vista && $vista->colonne_larghezze ? $vista->colonne_larghezze : [];
$gruppiQuery = Gruppo::with(['diocesi', 'parent', 'children', 'individui', 'avatar', 'tags']);
$gruppiQuery = Gruppo::with(['diocesi', 'gruppoContatti', 'parent', 'children', 'individui', 'avatar', 'tags']);
if (request()->filled('tag')) {
$tagSlugs = (array) request()->input('tag');
@@ -141,7 +143,7 @@ class GruppoController extends Controller
public function create(Request $request)
{
$this->authorizeWrite('gruppi');
$diocesi = Diocesi::orderBy('nome')->get();
$diocesi = Diocesi::orderBy('id')->get();
$allGruppi = Gruppo::orderBy('nome')->get();
$selectedParent = $request->query('parent_id') ? Gruppo::find($request->query('parent_id')) : null;
$individui = Individuo::orderBy('cognome')->orderBy('nome')->get();
@@ -154,9 +156,11 @@ class GruppoController extends Controller
$this->authorizeWrite('gruppi');
$data = $request->validate([
'nome' => 'required|string|max:255',
'anno_fondazione' => 'nullable|integer|min:1800|max:' . (date('Y') + 1),
'descrizione' => 'nullable|string',
'parent_id' => 'nullable|exists:gruppi,id',
'diocesi_id' => 'nullable|exists:diocesi,id',
'diocesi_ids' => 'nullable|array',
'diocesi_ids.*' => 'exists:diocesi,id',
'responsabile_ids' => 'nullable|array',
'responsabile_ids.*' => 'exists:individui,id',
'indirizzo_incontro' => 'nullable|string|max:500',
@@ -164,6 +168,11 @@ class GruppoController extends Controller
'città_incontro' => 'nullable|string|max:255',
'sigla_provincia_incontro' => 'nullable|string|max:2',
'mappa_posizione' => 'nullable|string',
'contatti' => 'nullable|array',
'contatti.*.tipo' => 'required|string|in:email,telefono,cellulare',
'contatti.*.valore' => 'required|string|max:255',
'contatti.*.etichetta' => 'nullable|string|max:255',
'contatti.*.is_primary' => 'nullable|boolean',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
@@ -174,6 +183,21 @@ class GruppoController extends Controller
$gruppo = Gruppo::create($data);
if (!empty($data['diocesi_ids'])) {
$gruppo->diocesi()->sync($data['diocesi_ids']);
}
if (!empty($data['contatti'])) {
foreach ($data['contatti'] as $contattoData) {
$gruppo->gruppoContatti()->create([
'tipo' => $contattoData['tipo'],
'valore' => $contattoData['valore'],
'etichetta' => $contattoData['etichetta'] ?? null,
'is_primary' => $contattoData['is_primary'] ?? false,
]);
}
}
if ($request->has('tags')) {
$gruppo->tags()->sync($request->tags);
}
@@ -195,7 +219,7 @@ class GruppoController extends Controller
public function show($id)
{
$this->authorizeRead('gruppi');
$gruppo = Gruppo::with(['parent', 'children', 'diocesi', 'individui.contatti', 'individui.documenti', 'individui' => function ($q) {
$gruppo = Gruppo::with(['parent', 'children', 'diocesi', 'gruppoContatti', 'individui.contatti', 'individui.documenti', 'individui' => function ($q) {
$q->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione');
}, 'avatar', 'tags', 'eventi' => function ($q) {
$q->where('is_incontro_gruppo', true);
@@ -206,13 +230,14 @@ class GruppoController extends Controller
public function edit($id)
{
$this->authorizeWrite('gruppi');
$gruppo = Gruppo::with(['individui.contatti', 'individui.documenti', 'avatar', 'tags'])->findOrFail($id);
$diocesi = Diocesi::orderBy('nome')->get();
$gruppo = Gruppo::with(['individui.contatti', 'individui.documenti', 'gruppoContatti', 'avatar', 'tags'])->findOrFail($id);
$diocesi = Diocesi::orderBy('id')->get();
$gruppi = Gruppo::where('id', '!=', $gruppo->id)->orderBy('nome')->get();
$membri = $gruppo->individui()->withPivot('ruolo_ids', 'ruolo_nel_gruppo', 'data_adesione')->orderBy('cognome')->orderBy('nome')->get();
$tags = Tag::orderBy('name')->get();
$selectedTags = $gruppo->tags->pluck('id')->toArray();
return view('gruppi.edit', compact('gruppo', 'diocesi', 'gruppi', 'membri', 'tags', 'selectedTags'));
$selectedDiocesiIds = $gruppo->diocesi()->pluck('diocesi.id')->toArray();
return view('gruppi.edit', compact('gruppo', 'diocesi', 'gruppi', 'membri', 'tags', 'selectedTags', 'selectedDiocesiIds'));
}
public function update(Request $request, $id)
@@ -221,9 +246,11 @@ class GruppoController extends Controller
$gruppo = Gruppo::findOrFail($id);
$data = $request->validate([
'nome' => 'required|string|max:255',
'anno_fondazione' => 'nullable|integer|min:1800|max:' . (date('Y') + 1),
'descrizione' => 'nullable|string',
'parent_id' => 'nullable|exists:gruppi,id',
'diocesi_id' => 'nullable|exists:diocesi,id',
'diocesi_ids' => 'nullable|array',
'diocesi_ids.*' => 'exists:diocesi,id',
'responsabile_ids' => 'nullable|array',
'responsabile_ids.*' => 'exists:individui,id',
'indirizzo_incontro' => 'nullable|string|max:500',
@@ -231,6 +258,11 @@ class GruppoController extends Controller
'città_incontro' => 'nullable|string|max:255',
'sigla_provincia_incontro' => 'nullable|string|max:2',
'mappa_posizione' => 'nullable|string',
'contatti' => 'nullable|array',
'contatti.*.tipo' => 'required|string|in:email,telefono,cellulare',
'contatti.*.valore' => 'required|string|max:255',
'contatti.*.etichetta' => 'nullable|string|max:255',
'contatti.*.is_primary' => 'nullable|boolean',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
@@ -243,6 +275,24 @@ class GruppoController extends Controller
$gruppo->update($data);
if ($request->has('diocesi_ids')) {
$gruppo->diocesi()->sync($data['diocesi_ids'] ?? []);
} else {
$gruppo->diocesi()->detach();
}
if ($request->has('contatti')) {
$gruppo->gruppoContatti()->delete();
foreach ($data['contatti'] as $contattoData) {
$gruppo->gruppoContatti()->create([
'tipo' => $contattoData['tipo'],
'valore' => $contattoData['valore'],
'etichetta' => $contattoData['etichetta'] ?? null,
'is_primary' => $contattoData['is_primary'] ?? false,
]);
}
}
if ($request->has('tags')) {
$gruppo->tags()->sync($request->tags);
} else {
@@ -385,9 +435,12 @@ class GruppoController extends Controller
'file' => 'required|file|mimes:csv,txt',
]);
set_time_limit(0);
session_write_close();
$file = $request->file('file');
$handle = fopen($file->path(), 'r');
$header = fgetcsv($handle, 1000, ',');
$header = fgetcsv($handle, 0, ',');
if ($header === false) {
fclose($handle);
@@ -400,7 +453,9 @@ class GruppoController extends Controller
$errors = [];
$rowNumber = 1;
while (($row = fgetcsv($handle, 1000, ',')) !== false) {
DB::beginTransaction();
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$rowNumber++;
if (count($row) < count($header)) {
@@ -426,6 +481,7 @@ class GruppoController extends Controller
try {
Gruppo::create([
'nome' => $nome,
'anno_fondazione' => !empty($data['anno_fondazione']) ? (int) $data['anno_fondazione'] : null,
'descrizione' => $data['descrizione'] ?? null,
'parent_id' => !empty($data['parent_id']) ? (int) $data['parent_id'] : null,
'diocesi_id' => !empty($data['diocesi_id']) ? (int) $data['diocesi_id'] : null,
@@ -444,6 +500,8 @@ class GruppoController extends Controller
fclose($handle);
DB::commit();
$message = 'Importati ' . $imported . ' gruppi.';
if ($skipped > 0) {
$message .= ' Saltate ' . $skipped . ' righe (vuote o incomplete).';
@@ -458,8 +516,8 @@ class GruppoController extends Controller
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'];
$headers = ['nome', 'anno_fondazione', 'descrizione', 'parent_id', 'diocesi_id', 'indirizzo_incontro', 'cap_incontro', 'città_incontro', 'sigla_provincia_incontro'];
$example = ['Gruppo Giovani', '2020', 'Giovani dai 18 ai 30 anni', '', '', 'Via Roma 10', '00100', 'Roma', 'RM'];
$csv = implode(',', $headers) . "\n" . implode(',', $example);
@@ -3,6 +3,8 @@
namespace App\Http\Controllers;
use App\Models\Gruppo;
use App\Models\Individuo;
use App\Models\Ruolo;
use Illuminate\Http\Request;
class GruppoMembroController extends Controller
+1
View File
@@ -10,6 +10,7 @@ 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;
@@ -10,10 +10,12 @@ use App\Models\Ruolo;
use App\Models\SenderAccount;
use App\Models\StorageRepository;
use App\Models\Tag;
use App\Services\GoogleOAuthService;
use App\Models\TipologiaDocumento;
use App\Models\TipologiaEvento;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@@ -33,8 +35,9 @@ class ImpostazioniController extends Controller
$repositories = StorageRepository::orderBy('ordine')->get();
$googleDriveNewToken = session('google_drive_new_token');
$calendarioConnessioni = CalendarioConnessione::orderBy('ordine')->get();
$googleStatus = app(GoogleOAuthService::class)->getConnectionStatus();
return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'tags', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni'));
return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'tags', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni', 'googleStatus'));
}
public function saveAppSettings(Request $request)
+31 -12
View File
@@ -1,18 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Individuo;
use App\Models\Contatto;
use App\Models\Documento;
use App\Models\Gruppo;
use App\Models\Comune;
use App\Models\Diocesi;
use App\Models\Tag;
use App\Models\VistaReport;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
use App\Models\Gruppo;
use App\Models\Comune;
use App\Models\Diocesi;
use App\Models\Tag;
use App\Models\VistaReport;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
class IndividuoController extends Controller
{
@@ -147,7 +152,7 @@ public function create()
{
$this->authorizeWrite('individui');
$comuni = Comune::orderBy('nome')->get();
$diocesi = Diocesi::orderBy('nome')->get();
$diocesi = Diocesi::orderBy('id')->get();
$tags = Tag::orderBy('name')->get();
return view('individui.create', compact('comuni', 'diocesi', 'tags'));
}
@@ -496,16 +501,27 @@ public function emailList(Request $request)
'file' => 'required|file|mimes:csv,txt',
]);
set_time_limit(0);
session_write_close();
$file = $request->file('file');
$handle = fopen($file->path(), 'r');
$header = fgetcsv($handle, 1000, ',');
$header = fgetcsv($handle, 0, ',');
if ($header === false) {
fclose($handle);
return back()->with('error', 'Il file CSV non è valido.');
}
$header = array_map('trim', $header);
$imported = 0;
$skipped = 0;
$errors = [];
$rowNumber = 1;
while (($row = fgetcsv($handle, 1000, ',')) !== false) {
DB::beginTransaction();
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$rowNumber++;
if (count($row) < count($header)) {
@@ -564,12 +580,15 @@ public function emailList(Request $request)
$imported++;
} catch (\Exception $e) {
Log::warning('Errore import individuo riga ' . $rowNumber . ': ' . $e->getMessage());
$errors[] = 'Errore riga ' . $rowNumber . ': ' . $e->getMessage();
}
}
fclose($handle);
DB::commit();
$message = 'Importati ' . $imported . ' individui.';
if ($skipped > 0) {
$message .= ' Saltate ' . $skipped . ' righe (vuote o incomplete).';
+91 -27
View File
@@ -10,6 +10,8 @@ use App\Models\MailingMessaggio;
use App\Models\SenderAccount;
use App\Models\Individuo;
use App\Models\Documento;
use App\Models\EmailSetting;
use App\Models\Firma;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@@ -30,6 +32,8 @@ class MailingController extends Controller
$liste = MailingList::all();
$documenti = Documento::orderBy('nome_file')->get();
$senderAccounts = SenderAccount::active()->get();
$settings = EmailSetting::getActive();
$firme = $settings?->firme ?? collect();
$documentiSelezionati = collect();
if ($request->has('documenti_selezionati')) {
@@ -37,7 +41,7 @@ class MailingController extends Controller
$documentiSelezionati = Documento::whereIn('id', $docIds)->get();
}
return view('mailing.nuovo', compact('individui', 'liste', 'documenti', 'documentiSelezionati', 'senderAccounts'));
return view('mailing.nuovo', compact('individui', 'liste', 'documenti', 'documentiSelezionati', 'senderAccounts', 'firme'));
}
public function invia(Request $request)
@@ -46,16 +50,22 @@ class MailingController extends Controller
$data = $request->validate([
'oggetto' => 'required|string|max:255',
'corpo' => 'required',
'lista_id' => 'nullable|exists:mailing_liste,id',
'lista_id' => 'nullable|exists:mailing_lists,id',
'destinatari_ids' => 'nullable|string',
'documenti_selezionati' => 'nullable|string',
'allegato' => 'nullable|file|max:10240',
'mittente_id' => 'nullable|integer|exists:sender_accounts,id',
'firma_id' => 'nullable|integer|exists:firme,id',
]);
$sender = null;
if (!empty($data['mittente_id'])) {
$sender = SenderAccount::findOrFail($data['mittente_id']);
} elseif (!empty($data['lista_id'])) {
$lista = MailingList::with('senderAccount')->find($data['lista_id']);
if ($lista && $lista->senderAccount) {
$sender = $lista->senderAccount;
}
}
$documentiAllegati = [];
@@ -110,7 +120,23 @@ class MailingController extends Controller
return back()->with('error', 'Nessun destinatario email valido trovato.')->withInput();
}
$attachmentPaths = $this->resolveMailingAttachmentPaths($documentiAllegati);
$attachmentResult = $this->resolveMailingAttachmentPaths($documentiAllegati);
$attachmentPaths = $attachmentResult['paths'];
$linkDocs = $attachmentResult['links'];
$firmaId = $data['firma_id'] ?? null;
if (!$firmaId && !empty($data['lista_id'])) {
$lista = MailingList::with('firma')->find($data['lista_id']);
$firmaId = $lista?->firma?->id;
}
$corpoConFirma = $this->appendMailingLinksToBody($data['corpo'], $linkDocs);
if ($firmaId) {
$firma = Firma::find($firmaId);
if ($firma && $firma->contenuto_html) {
$corpoConFirma .= "\n\n---\n" . $firma->contenuto_html;
}
}
$messaggio = MailingMessaggio::create([
'mailing_list_id' => $data['lista_id'] ?? null,
@@ -121,6 +147,7 @@ class MailingController extends Controller
'totale_destinatari' => $emails->count(),
'mittente_nome' => $sender?->email_name,
'mittente_email' => $sender?->email_address,
'firma_id' => $firmaId,
]);
$ok = 0;
@@ -130,9 +157,9 @@ class MailingController extends Controller
foreach ($emails as $email) {
try {
if ($sender) {
$sender->sendEmail($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
$sender->sendEmail($email, $data['oggetto'], $corpoConFirma, $attachmentPaths);
} else {
$this->sendViaSystem($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
$this->sendViaSystem($email, $data['oggetto'], $corpoConFirma, $attachmentPaths);
}
$ok++;
} catch (\Throwable $e) {
@@ -181,11 +208,13 @@ class MailingController extends Controller
public function invio(Request $request)
{
$this->authorizeWrite('mailing');
$liste = MailingList::where('attiva', true)->orderBy('nome')->get();
$liste = MailingList::with('firma')->where('attiva', true)->orderBy('nome')->get();
$documenti = Documento::orderBy('nome_file')->get();
$senderAccounts = SenderAccount::active()->get();
$settings = EmailSetting::getActive();
$firme = $settings?->firme ?? collect();
return view('mailing.invio', compact('liste', 'documenti', 'senderAccounts'));
return view('mailing.invio', compact('liste', 'documenti', 'senderAccounts', 'firme'));
}
public function invioElabora(Request $request)
@@ -193,18 +222,23 @@ class MailingController extends Controller
$this->authorizeWrite('mailing');
$data = $request->validate([
'liste' => 'required|array',
'liste.*' => 'exists:mailing_liste,id',
'liste.*' => 'exists:mailing_lists,id',
'oggetto' => 'required|string|max:255',
'corpo' => 'required',
'documenti_selezionati' => 'nullable|array',
'documenti_selezionati.*' => 'exists:documenti,id',
'allegato' => 'nullable|file|max:10240',
'mittente_id' => 'nullable|integer|exists:sender_accounts,id',
'firma_id' => 'nullable|integer|exists:firme,id',
]);
$listeModels = MailingList::with('firma', 'senderAccount')->whereIn('id', $data['liste'])->get();
$sender = null;
if (!empty($data['mittente_id'])) {
$sender = SenderAccount::findOrFail($data['mittente_id']);
} elseif ($listeModels->count() === 1) {
$sender = $listeModels->first()->senderAccount;
}
$documentiAllegati = [];
@@ -252,9 +286,24 @@ class MailingController extends Controller
return back()->with('error', 'Nessun destinatario email valido trovato nelle liste selezionate.');
}
$attachmentPaths = $this->resolveMailingAttachmentPaths($documentiAllegati);
$attachmentResult = $this->resolveMailingAttachmentPaths($documentiAllegati);
$attachmentPaths = $attachmentResult['paths'];
$linkDocs = $attachmentResult['links'];
$listeNomi = MailingList::whereIn('id', $data['liste'])->pluck('nome')->implode(', ');
$firmaId = $data['firma_id'] ?? null;
if (!$firmaId && $listeModels->count() === 1) {
$firmaId = $listeModels->first()->firma_id;
}
$corpoConFirma = $this->appendMailingLinksToBody($data['corpo'], $linkDocs);
if ($firmaId) {
$firma = Firma::find($firmaId);
if ($firma && $firma->contenuto_html) {
$corpoConFirma .= "\n\n---\n" . $firma->contenuto_html;
}
}
$listeNomi = $listeModels->pluck('nome')->implode(', ');
$messaggio = MailingMessaggio::create([
'mailing_list_id' => null,
@@ -265,6 +314,7 @@ class MailingController extends Controller
'totale_destinatari' => $emails->count(),
'mittente_nome' => $sender?->email_name,
'mittente_email' => $sender?->email_address,
'firma_id' => $firmaId,
]);
$ok = 0;
@@ -274,9 +324,9 @@ class MailingController extends Controller
foreach ($emails as $email) {
try {
if ($sender) {
$sender->sendEmail($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
$sender->sendEmail($email, $data['oggetto'], $corpoConFirma, $attachmentPaths);
} else {
$this->sendViaSystem($email, $data['oggetto'], $data['corpo'], $attachmentPaths);
$this->sendViaSystem($email, $data['oggetto'], $corpoConFirma, $attachmentPaths);
}
$ok++;
} catch (\Throwable $e) {
@@ -347,18 +397,10 @@ class MailingController extends Controller
$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);
$fromAddress = \Symfony\Component\Mime\Address::create(
$settings->getEffectiveFromAddress(),
$settings->getEffectiveFromName()
);
$email = (new \Symfony\Component\Mime\Email())
->from($fromAddress)
@@ -366,6 +408,11 @@ class MailingController extends Controller
->subject($subject)
->text($body);
$replyTo = $settings->getEffectiveReplyTo();
if ($replyTo) {
$email->replyTo($replyTo);
}
foreach ($attachmentPaths as $path) {
$email->attachFromPath($path);
}
@@ -376,17 +423,34 @@ class MailingController extends Controller
private function resolveMailingAttachmentPaths(array $documentIds): array
{
$paths = [];
$links = [];
if (empty($documentIds)) {
return $paths;
return ['paths' => $paths, 'links' => $links];
}
$docs = Documento::whereIn('id', $documentIds)->get();
foreach ($docs as $doc) {
if ($doc->file_path && Storage::exists($doc->file_path)) {
if ($doc->isLink()) {
$links[] = ['nome' => $doc->nome_file, 'url' => $doc->url];
} elseif ($doc->file_path && Storage::exists($doc->file_path)) {
$paths[] = Storage::path($doc->file_path);
}
}
return $paths;
return ['paths' => $paths, 'links' => $links];
}
private function appendMailingLinksToBody(string $body, array $linkDocs): string
{
if (empty($linkDocs)) {
return $body;
}
$linksText = "\n\n---\nDocumenti collegati:\n";
foreach ($linkDocs as $link) {
$linksText .= "- {$link['nome']}: {$link['url']}\n";
}
return $body . $linksText;
}
}
+53 -3
View File
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\MailingList;
use App\Models\SenderAccount;
use App\Models\Tag;
use Illuminate\Http\Request;
@@ -60,7 +61,10 @@ class MailingListController extends Controller
{
$this->authorizeWrite('mailing');
$tags = Tag::orderBy('name')->get();
return view('mailing-liste.create', compact('tags'));
$settings = \App\Models\EmailSetting::getActive();
$firme = $settings?->firme ?? collect();
$senderAccounts = SenderAccount::active()->get();
return view('mailing-liste.create', compact('tags', 'firme', 'senderAccounts'));
}
public function store(Request $request)
@@ -73,6 +77,8 @@ class MailingListController extends Controller
'contatti_json' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'firma_id' => 'nullable|integer|exists:firme,id',
'sender_account_id' => 'nullable|integer|exists:sender_accounts,id',
]);
$data['user_id'] = auth()->id();
@@ -83,6 +89,8 @@ class MailingListController extends Controller
'descrizione' => $data['descrizione'] ?? null,
'attiva' => $data['attiva'],
'user_id' => $data['user_id'],
'firma_id' => $data['firma_id'] ?? null,
'sender_account_id' => $data['sender_account_id'] ?? null,
]);
if ($request->has('tags')) {
@@ -117,10 +125,13 @@ class MailingListController extends Controller
public function edit($mailingList)
{
$this->authorizeWrite('mailing');
$mailingList = MailingList::with(['contatti.individuo.contatti', 'tags'])->findOrFail($mailingList);
$mailingList = MailingList::with(['contatti.individuo.contatti', 'tags', 'firma', 'senderAccount'])->findOrFail($mailingList);
$tags = Tag::orderBy('name')->get();
$selectedTags = $mailingList->tags->pluck('id')->toArray();
return view('mailing-liste.edit', compact('mailingList', 'tags', 'selectedTags'));
$settings = \App\Models\EmailSetting::getActive();
$firme = $settings?->firme ?? collect();
$senderAccounts = SenderAccount::active()->get();
return view('mailing-liste.edit', compact('mailingList', 'tags', 'selectedTags', 'firme', 'senderAccounts'));
}
public function update(Request $request, $mailingList)
@@ -135,6 +146,8 @@ class MailingListController extends Controller
'contatti_json' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'firma_id' => 'nullable|integer|exists:firme,id',
'sender_account_id' => 'nullable|integer|exists:sender_accounts,id',
]);
$data['attiva'] = $data['attiva'] ?? false;
@@ -143,6 +156,8 @@ class MailingListController extends Controller
'nome' => $data['nome'],
'descrizione' => $data['descrizione'] ?? null,
'attiva' => $data['attiva'],
'firma_id' => $data['firma_id'] ?? null,
'sender_account_id' => $data['sender_account_id'] ?? null,
]);
if ($request->has('tags')) {
@@ -186,6 +201,41 @@ class MailingListController extends Controller
return redirect()->route('mailing-liste.index')->with('success', 'Lista eliminata.');
}
public function massTag(Request $request)
{
$this->authorizeWrite('mailing');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return back()->with('error', 'Nessuna mailing list selezionata.');
}
$data = $request->validate([
'tags' => 'required|array',
'tags.*' => 'exists:tags,id',
'mode' => 'required|in:assign,remove',
]);
$tagIds = $data['tags'];
$mode = $data['mode'];
$count = 0;
MailingList::whereIn('id', $ids)->chunk(100, function ($lists) use ($tagIds, $mode, &$count) {
foreach ($lists as $list) {
if ($mode === 'assign') {
$list->tags()->syncWithoutDetaching($tagIds);
} else {
$list->tags()->detach($tagIds);
}
$count++;
}
});
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return back()->with('success', "Tag $actionLabel per $count mailing list.");
}
public function massElimina(Request $request)
{
$this->authorizeDelete('mailing');
+4 -4
View File
@@ -515,7 +515,7 @@ class ReportController extends Controller
$rows[] = [
'livello' => 0,
'nome' => $gruppo->nome,
'diocesi' => $gruppo->diocesi?->nome ?? '-',
'diocesi' => $gruppo->diocesi->count() > 0 ? $gruppo->diocesi->pluck('nome')->implode(', ') : '-',
'membri' => $gruppo->individui()->count(),
'padre' => '-',
];
@@ -538,7 +538,7 @@ class ReportController extends Controller
$rows[] = [
'livello' => $level,
'nome' => str_repeat(' ', $level) . $child->nome,
'diocesi' => $child->diocesi?->nome ?? '-',
'diocesi' => $child->diocesi->count() > 0 ? $child->diocesi->pluck('nome')->implode(', ') : '-',
'membri' => $child->individui()->count(),
'padre' => $parent?->nome ?? '-',
];
@@ -554,7 +554,7 @@ class ReportController extends Controller
$parent = $g->parent;
return [
'nome' => $g->full_path,
'diocesi' => $g->diocesi?->nome ?? '-',
'diocesi' => $g->diocesi->count() > 0 ? $g->diocesi->pluck('nome')->implode(', ') : '-',
'membri' => $g->individui_count,
'padre' => $parent?->nome ?? '-',
'responsabili' => $g->getResponsabili()->pluck('cognome')->implode(', ') ?: '-',
@@ -895,7 +895,7 @@ class ReportController extends Controller
return [
'nome' => $g->nome,
'descrizione' => $g->descrizione ?? '-',
'diocesi' => $g->diocesi?->nome ?? '-',
'diocesi' => $g->diocesi->count() > 0 ? $g->diocesi->pluck('nome')->implode(', ') : '-',
'livello' => $g->parent_id ? (count($g->getAncestors()) + 1) : 0,
'membri' => $g->individui_count,
'padre' => $parent?->nome ?? '-',
@@ -10,6 +10,7 @@ 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
@@ -18,7 +19,20 @@ class StorageRepositoryController extends Controller
private readonly StorageRepositoryService $repoService
) {}
public function store(Request $request): RedirectResponse
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');
+1
View File
@@ -5,6 +5,7 @@ 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;
+4 -2
View File
@@ -53,11 +53,13 @@ class CalendarioConnessione extends Model
return $config;
}
public function encryptAndSetConfig(array $config): void
public function encryptAndSetConfig(array $config, array $existingConfig = []): void
{
foreach (['username', 'password', 'client_secret', 'refresh_token', 'api_key'] as $field) {
if (isset($config[$field]) && $config[$field] !== '' && !str_starts_with($config[$field], 'eyJpdiI')) {
if (!empty($config[$field]) && !str_starts_with($config[$field], 'eyJpdiI')) {
$config[$field] = Crypt::encryptString($config[$field]);
} elseif (empty($config[$field]) && !empty($existingConfig[$field])) {
$config[$field] = $existingConfig[$field];
}
}
$this->config = $config;
+3 -3
View File
@@ -3,15 +3,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Diocesi extends Model
{
protected $table = 'diocesi';
protected $fillable = ['nome', 'regione'];
public function gruppi(): HasMany
public function gruppi(): BelongsToMany
{
return $this->hasMany(Gruppo::class);
return $this->belongsToMany(Gruppo::class, 'diocesi_gruppo');
}
}
+8 -1
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Traits\HasTagsLight;
@@ -16,9 +18,14 @@ class Documento extends Model
'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'
'mime_type', 'dimensione', 'note', 'url'
];
public function isLink(): bool
{
return $this->tipo === 'link';
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
+1
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class EmailAttachment extends Model
{
+148 -62
View File
@@ -2,10 +2,10 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Enums\GoogleService;
use App\Services\GoogleOAuthService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Crypt;
class EmailSetting extends Model
@@ -15,14 +15,17 @@ class EmailSetting extends Model
protected $fillable = [
'imap_host', 'imap_port', 'imap_encryption', 'imap_username', 'imap_password',
'smtp_host', 'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password',
'email_address', 'email_name', 'reply_to', 'sync_interval_minutes',
'last_sync_at', 'is_active', 'signature', 'signature_enabled', 'auth_method'
'email_address', 'email_name', 'from_email', 'from_name', 'reply_to',
'sync_interval_minutes',
'last_sync_at', 'is_active', 'signature', 'signature_enabled',
'auth_method', 'google_oauth_connection_id',
];
protected $casts = [
'is_active' => 'boolean',
'signature_enabled' => 'boolean',
'last_sync_at' => 'datetime',
'google_oauth_connection_id' => 'integer',
];
public function folders(): HasMany
@@ -30,8 +33,25 @@ class EmailSetting extends Model
return $this->hasMany(EmailFolder::class)->orderBy('sort_order');
}
public function googleOAuthConnection(): BelongsTo
{
return $this->belongsTo(GoogleOAuthConnection::class);
}
public function getImapConfig(): array
{
if ($this->auth_method === 'oauth') {
$token = $this->resolveOAuthToken();
return [
'host' => $this->imap_host,
'port' => $this->imap_port,
'encryption' => $this->imap_encryption,
'username' => $this->imap_username,
'authentication' => 'oauth',
'password' => $token,
];
}
return [
'host' => $this->imap_host,
'port' => $this->imap_port,
@@ -43,9 +63,10 @@ class EmailSetting extends Model
public function getDecryptedPassword(): string
{
if ($this->isOauth()) {
return $this->getOAuthAccessToken() ?? '';
if ($this->auth_method === 'oauth') {
return $this->resolveOAuthToken();
}
if (empty($this->imap_password)) {
return '';
}
@@ -58,9 +79,10 @@ class EmailSetting extends Model
public function getDecryptedSmtpPassword(): string
{
if ($this->isOauth()) {
return $this->getOAuthAccessToken() ?? '';
if ($this->auth_method === 'oauth') {
return $this->resolveOAuthToken();
}
if (empty($this->smtp_password)) {
return $this->getDecryptedPassword();
}
@@ -71,23 +93,19 @@ class EmailSetting extends Model
}
}
public function isOauth(): bool
protected function resolveOAuthToken(): string
{
return $this->auth_method === 'oauth';
}
public function getOAuthAccessToken(): ?string
{
if (!$this->isOauth()) {
return null;
if (!$this->relationLoaded('googleOAuthConnection')) {
$this->load('googleOAuthConnection');
}
$connection = $this->googleOAuthConnection;
if (!$connection) {
return '';
}
try {
$oauth = app(GoogleOAuthService::class);
return $oauth->getAccessToken(GoogleService::Gmail);
return app(GoogleOAuthService::class)->getAccessToken($connection);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('OAuth token refresh failed: ' . $e->getMessage());
return null;
return '';
}
}
@@ -108,10 +126,6 @@ class EmailSetting extends Model
return null;
}
if ($this->isOauth()) {
return null;
}
try {
$encryption = match ($this->imap_encryption) {
'ssl' => 'ssl',
@@ -119,37 +133,101 @@ class EmailSetting extends Model
default => 'none',
};
return new \DirectoryTree\ImapEngine\Mailbox([
$config = [
'host' => $this->imap_host,
'port' => $this->imap_port ?? 993,
'encryption' => $encryption,
'validate_cert' => false,
'username' => $this->imap_username,
'password' => $this->getDecryptedPassword(),
]);
];
if ($this->auth_method === 'oauth') {
$config['authentication'] = 'oauth';
}
return new \DirectoryTree\ImapEngine\Mailbox($config);
} catch (\Exception $e) {
return null;
}
}
public function getSmtpMailer(): ?\Symfony\Component\Mailer\Mailer
{
if ($this->auth_method === 'oauth') {
return $this->buildOAuthSmtpMailer();
}
$dsn = $this->buildSmtpDsn();
if (!$dsn) {
return null;
}
try {
$transport = \Symfony\Component\Mailer\Transport::fromDsn($dsn);
return new \Symfony\Component\Mailer\Mailer($transport);
} catch (\Exception $e) {
return null;
}
}
protected function buildOAuthSmtpMailer(): ?\Symfony\Component\Mailer\Mailer
{
$token = $this->resolveOAuthToken();
if (empty($token)) {
return null;
}
$username = $this->smtp_username ?? $this->email_address;
$host = $this->smtp_host ?? 'smtp.gmail.com';
$port = $this->smtp_port ?? 587;
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport($host, $port, true);
$transport->setUsername($username);
$transport->setPassword($token);
$transport->setAuthenticators([
new \Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator(),
]);
return new \Symfony\Component\Mailer\Mailer($transport);
}
public function buildSmtpDsn(): ?string
{
$password = $this->getDecryptedSmtpPassword();
if (empty($password)) {
return null;
}
$username = $this->smtp_username ?? $this->email_address;
$encryption = $this->smtp_encryption ?? 'tls';
$scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp';
return sprintf(
'%s://%s:%s@%s:%d?encryption=%s',
$scheme,
rawurlencode($username),
rawurlencode($password),
$this->smtp_host ?? 'smtp.gmail.com',
$this->smtp_port ?? 587,
$encryption
);
}
public function testConnection(): array
{
try {
if ($this->isOauth()) {
return $this->testOAuthImapConnection();
}
$mailbox = $this->getImapClient();
if (!$mailbox) {
return ['success' => false, 'message' => 'Configurazione IMAP non valida'];
}
$mailbox->connect();
$mailbox->disconnect();
return [
'success' => true,
'success' => true,
'message' => 'Connessione IMAP riuscita! Server: ' . $this->imap_host
];
} catch (\Exception $e) {
@@ -161,32 +239,6 @@ class EmailSetting extends Model
}
}
private function testOAuthImapConnection(): array
{
try {
$token = $this->getOAuthAccessToken();
if (!$token) {
return ['success' => false, 'message' => 'Impossibile ottenere il token OAuth. Verifica la connessione Google.'];
}
$client = new \Webklex\PHPIMAP\Client([
'host' => $this->imap_host,
'port' => $this->imap_port ?? 993,
'encryption' => $this->imap_encryption ?? 'ssl',
'validate_cert' => false,
'username' => $this->imap_username,
'password' => $token,
'authentication' => 'oauth',
]);
$client->connect();
$client->disconnect();
return ['success' => true, 'message' => 'Connessione IMAP OAuth riuscita! Server: ' . $this->imap_host];
} catch (\Exception $e) {
return ['success' => false, 'message' => 'Errore connessione IMAP OAuth: ' . $e->getMessage()];
}
}
public static function getActive()
{
return self::where('is_active', true)->first();
@@ -196,4 +248,38 @@ class EmailSetting extends Model
{
return $this->signature;
}
}
public function getEffectiveFromAddress(): string
{
return $this->from_email ?: $this->email_address;
}
public function getEffectiveFromName(): string
{
$name = $this->from_name ?: $this->email_name;
if (filter_var($name, FILTER_VALIDATE_EMAIL)) {
return 'Glastree';
}
$name = preg_replace('/[^\p{L}\p{N}\s]/u', '', $name);
$name = trim($name);
return empty($name) ? 'Glastree' : $name;
}
public function getEffectiveReplyTo(): ?string
{
if ($this->from_email) {
return $this->from_email;
}
return $this->reply_to ?: null;
}
public function firme(): HasMany
{
return $this->hasMany(Firma::class);
}
public function firmaPredefinita(): ?Firma
{
return $this->firme()->where('is_default', true)->first() ?? $this->firme()->first();
}
}
+3
View File
@@ -1,11 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Traits\HasTagsLight;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Evento extends Model
{
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Firma extends Model
{
protected $table = 'firme';
protected $fillable = [
'email_setting_id',
'nome',
'contenuto_html',
'is_default',
];
protected $casts = [
'is_default' => 'boolean',
];
public function emailSetting(): BelongsTo
{
return $this->belongsTo(EmailSetting::class);
}
}
+27 -20
View File
@@ -4,43 +4,50 @@ declare(strict_types=1);
namespace App\Models;
use App\Enums\GoogleService;
use App\Services\GoogleOAuthService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GoogleOAuthConnection extends Model
{
protected $table = 'google_oauth_connections';
protected $fillable = [
'user_id',
'service',
'client_id',
'client_secret',
'email',
'access_token',
'refresh_token',
'expires_at',
];
protected function casts(): array
protected $casts = [
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return [
'service' => GoogleService::class,
];
return $this->belongsTo(User::class);
}
public function getClientSecretAttribute(?string $value): ?string
public function isExpired(): bool
{
return $value ? Crypt::decryptString($value) : null;
return $this->expires_at && $this->expires_at->isPast();
}
public function setClientSecretAttribute(?string $value): void
public function refresh(): void
{
$this->attributes['client_secret'] = $value ? Crypt::encryptString($value) : null;
if (!$this->refresh_token) {
throw new \RuntimeException('Nessun refresh token disponibile per ' . $this->email);
}
app(GoogleOAuthService::class)->refreshToken($this);
}
public function getRefreshTokenAttribute(?string $value): ?string
public function getValidAccessToken(): string
{
return $value ? Crypt::decryptString($value) : null;
if ($this->isExpired() && $this->refresh_token) {
$this->refresh();
}
return $this->access_token;
}
public function setRefreshTokenAttribute(?string $value): void
{
$this->attributes['refresh_token'] = $value ? Crypt::encryptString($value) : null;
}
}
}
+23 -3
View File
@@ -15,15 +15,18 @@ class Gruppo extends Model
protected $table = 'gruppi';
protected $fillable = [
'tenant_id', 'parent_id', 'diocesi_id', 'responsabile_ids',
'nome', 'descrizione', 'indirizzo_incontro', 'cap_incontro',
'nome', 'anno_fondazione', 'descrizione', 'indirizzo_incontro', 'cap_incontro',
'città_incontro', 'sigla_provincia_incontro', 'mappa_posizione', 'metadata'
];
protected $casts = [
'metadata' => 'array',
'responsabile_ids' => 'array',
'anno_fondazione' => 'integer',
];
protected $appends = ['email_primaria', 'telefono_primario'];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
@@ -39,9 +42,14 @@ class Gruppo extends Model
return $this->hasMany(Gruppo::class, 'parent_id');
}
public function diocesi(): BelongsTo
public function diocesi(): BelongsToMany
{
return $this->belongsTo(Diocesi::class);
return $this->belongsToMany(Diocesi::class, 'diocesi_gruppo');
}
public function gruppoContatti(): HasMany
{
return $this->hasMany(GruppoContatto::class, 'gruppo_id');
}
public function individui(): BelongsToMany
@@ -66,6 +74,18 @@ class Gruppo extends Model
return null;
}
public function getEmailPrimariaAttribute(): ?string
{
return $this->gruppoContatti()->where('tipo', 'email')->where('is_primary', true)->first()?->valore
?? $this->gruppoContatti()->where('tipo', 'email')->first()?->valore;
}
public function getTelefonoPrimarioAttribute(): ?string
{
return $this->gruppoContatti()->whereIn('tipo', ['telefono', 'cellulare'])->where('is_primary', true)->first()?->valore
?? $this->gruppoContatti()->whereIn('tipo', ['telefono', 'cellulare'])->first()?->valore;
}
public function getResponsabili()
{
if (empty($this->responsabile_ids)) {
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GruppoContatto extends Model
{
protected $table = 'gruppo_contatti';
protected $fillable = ['gruppo_id', 'tipo', 'valore', 'etichetta', 'is_primary'];
protected $casts = [
'is_primary' => 'boolean',
];
public function gruppo(): BelongsTo
{
return $this->belongsTo(Gruppo::class);
}
}
+11 -1
View File
@@ -12,7 +12,7 @@ class MailingList extends Model
use HasTagsLight;
protected $table = 'mailing_lists';
protected $fillable = ['tenant_id', 'user_id', 'nome', 'descrizione', 'attiva'];
protected $fillable = ['tenant_id', 'user_id', 'nome', 'descrizione', 'attiva', 'firma_id', 'sender_account_id'];
protected $casts = ['attiva' => 'boolean'];
@@ -36,6 +36,16 @@ class MailingList extends Model
return $this->hasMany(MailingMessaggio::class);
}
public function firma(): BelongsTo
{
return $this->belongsTo(Firma::class);
}
public function senderAccount(): BelongsTo
{
return $this->belongsTo(SenderAccount::class);
}
public function getIndividui()
{
return Individuo::whereHas('mailingContacts', function ($q) {
+1 -1
View File
@@ -11,7 +11,7 @@ class MailingMessaggio extends Model
protected $fillable = [
'tenant_id', 'mailing_list_id', 'user_id', 'oggetto', 'corpo',
'canale', 'stato', 'inviato_al', 'totale_destinatari', 'invii_ok', 'invii_falliti',
'log_falliti', 'mittente_nome', 'mittente_email',
'log_falliti', 'mittente_nome', 'mittente_email', 'firma_id',
];
protected $casts = [
+54 -2
View File
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Models;
use App\Services\GoogleOAuthService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Crypt;
class SenderAccount extends Model
@@ -24,11 +26,14 @@ class SenderAccount extends Model
'verify_email',
'note',
'is_active',
'auth_method',
'google_oauth_connection_id',
];
protected $casts = [
'is_active' => 'boolean',
'smtp_port' => 'integer',
'google_oauth_connection_id' => 'integer',
];
public function scopeActive(Builder $query): Builder
@@ -36,8 +41,33 @@ class SenderAccount extends Model
return $query->where('is_active', true);
}
public function googleOAuthConnection(): BelongsTo
{
return $this->belongsTo(GoogleOAuthConnection::class);
}
protected function resolveOAuthToken(): string
{
if (!$this->relationLoaded('googleOAuthConnection')) {
$this->load('googleOAuthConnection');
}
$connection = $this->googleOAuthConnection;
if (!$connection) {
return '';
}
try {
return app(GoogleOAuthService::class)->getAccessToken($connection);
} catch (\Exception $e) {
return '';
}
}
public function getDecryptedPassword(): string
{
if ($this->auth_method === 'oauth') {
return $this->resolveOAuthToken();
}
if (empty($this->smtp_password)) {
return '';
}
@@ -121,11 +151,33 @@ class SenderAccount extends Model
$email->attachFromPath($path);
}
$transport = \Symfony\Component\Mailer\Transport::fromDsn($this->buildDsn());
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
if ($this->auth_method === 'oauth') {
$mailer = $this->buildOAuthMailer();
} else {
$transport = \Symfony\Component\Mailer\Transport::fromDsn($this->buildDsn());
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
}
$mailer->send($email);
}
protected function buildOAuthMailer(): \Symfony\Component\Mailer\Mailer
{
$token = $this->resolveOAuthToken();
$username = $this->smtp_username ?? $this->email_address;
$host = $this->smtp_host ?? 'smtp.gmail.com';
$port = $this->smtp_port ?? 587;
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport($host, $port, true);
$transport->setUsername($username);
$transport->setPassword($token);
$transport->setAuthenticators([
new \Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator(),
]);
return new \Symfony\Component\Mailer\Mailer($transport);
}
public function sendReport(string $subject, string $body): bool
{
if (empty($this->verify_email)) {
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
+5 -4
View File
@@ -197,12 +197,13 @@ class User extends Authenticatable implements CanResetPasswordContract
$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');
$fromAddress = new Address(
$settings->getEffectiveFromAddress(),
$settings->getEffectiveFromName()
);
$email = (new Email())
->from(new Address($settings->email_address, $fromName))
->from($fromAddress)
->to($this->email)
->subject('Reset della password - ' . config('app.name'))
->html(view('auth.emails.reset-password', [
+1
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
use Spatie\Permission\Models\Permission;
class AuthServiceProvider extends ServiceProvider
+32 -10
View File
@@ -5,8 +5,11 @@ 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;
@@ -57,7 +60,7 @@ class BackupService
return $backups;
}
public function run(): array
public function run(array $options = []): array
{
$result = ['success' => true, 'message' => '', 'filename' => ''];
@@ -71,12 +74,28 @@ class BackupService
$filename = $appName . '_' . $timestamp . '.zip';
$filepath = $this->backupDir . '/' . $filename;
$includeFiles = $options['include_files'] ?? (bool) AppSetting::getSetting('backup_include_files', true);
$includeEnv = $options['include_env'] ?? (bool) AppSetting::getSetting('backup_include_env', true);
$steps = [];
$includedComponents = [];
$steps[] = $this->backupDatabase();
$steps[] = $this->backupEnv();
$steps[] = $this->backupFiles();
$this->writeManifest($timestamp);
$includedComponents[] = 'database';
$stepEnv = $this->backupEnv($includeEnv);
$steps[] = $stepEnv;
if ($includeEnv) {
$includedComponents[] = 'env';
}
$stepFiles = $this->backupFiles($includeFiles);
$steps[] = $stepFiles;
if ($includeFiles) {
$includedComponents[] = 'files';
}
$this->writeManifest($timestamp, $includedComponents);
$zipResult = $this->createZip($filepath);
if (!$zipResult) {
@@ -87,7 +106,7 @@ class BackupService
$size = file_exists($filepath) ? filesize($filepath) : 0;
Log::info("Backup completato: {$filename} (" . $this->formatBytes($size) . ")");
Log::info("Backup completato: {$filename} (" . $this->formatBytes($size) . ')');
$this->cleanupOldBackups();
@@ -97,6 +116,7 @@ class BackupService
'filename' => $filename,
'size' => $size,
'size_formatted' => $this->formatBytes($size),
'steps' => $steps,
];
} catch (\Exception $e) {
$this->cleanTemp();
@@ -106,6 +126,7 @@ class BackupService
'success' => false,
'message' => 'Backup fallito: ' . $e->getMessage(),
'filename' => '',
'steps' => [],
];
}
}
@@ -178,9 +199,9 @@ class BackupService
return 'database.sql (' . $this->formatBytes(filesize($sqlFile)) . ')';
}
private function backupEnv(): string
private function backupEnv(bool $includeEnv = true): string
{
if (!AppSetting::getSetting('backup_include_env', true)) {
if (!$includeEnv) {
return '.env (escluso)';
}
@@ -197,9 +218,9 @@ class BackupService
return '.env copiato';
}
private function backupFiles(): string
private function backupFiles(bool $includeFiles = true): string
{
if (!AppSetting::getSetting('backup_include_files', true)) {
if (!$includeFiles) {
return 'files (esclusi)';
}
@@ -217,7 +238,7 @@ class BackupService
return 'files copiati';
}
private function writeManifest(string $timestamp): void
private function writeManifest(string $timestamp, array $includedComponents = ['database']): void
{
$manifest = [
'created_at' => $timestamp,
@@ -227,6 +248,7 @@ class BackupService
'db_name' => config('database.connections.mysql.database'),
'php_version' => PHP_VERSION,
'laravel_version' => app()->version(),
'included_components' => $includedComponents,
];
File::put($this->tempDir . '/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT));
@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace App\Services;
use App\Enums\GoogleService;
use App\Models\CalendarioConnessione;
use App\Services\GoogleOAuthService;
use App\Models\Evento;
use Carbon\Carbon;
use Google\Client as GoogleClient;
@@ -329,16 +327,6 @@ class GoogleCalendarSyncService
private function buildClient(CalendarioConnessione $connessione): GoogleClient
{
try {
$oauth = app(GoogleOAuthService::class);
$connection = $oauth->getConnection(GoogleService::Calendar);
if ($connection) {
return $oauth->buildClient(GoogleService::Calendar);
}
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::warning('Central Calendar OAuth failed, falling back: ' . $e->getMessage());
}
$config = $connessione->getDecryptedConfig();
$client = new GoogleClient();
+132 -104
View File
@@ -4,147 +4,175 @@ declare(strict_types=1);
namespace App\Services;
use App\Enums\GoogleService;
use App\Models\GoogleOAuthConnection;
use Google\Client;
use App\Enums\GoogleService;
use Google_Client;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class GoogleOAuthService
{
private const TOKEN_URI = 'https://oauth2.googleapis.com/token';
private const TOKEN_CACHE_TTL = 300;
private const MAX_RETRIES = 2;
private static function getRedirectUri(): string
public function buildClient(): Google_Client
{
$subfolder = config('app.url');
return rtrim($subfolder, '/') . '/auth/google/callback';
}
public function getAuthorizationUrl(GoogleService $service): string
{
$client = $this->createClient($service);
$client->setRedirectUri(self::getRedirectUri());
$client->setState($service->value);
$client = new Google_Client();
$client->setClientId(config('services.google.client_id'));
$client->setClientSecret(config('services.google.client_secret'));
$client->setRedirectUri(route('google-oauth.callback'));
$client->setAccessType('offline');
$client->setPrompt('consent');
$client->setIncludeGrantedScopes(true);
return $client;
}
public function getAuthUrl(string $service): string
{
$serviceEnum = GoogleService::from($service);
$client = $this->buildClient();
$client->setScopes($serviceEnum->scopes());
$client->setState($service);
return $client->createAuthUrl();
}
public function handleCallback(string $code, GoogleService $service): GoogleOAuthConnection
public function handleCallback(string $authorizationCode, GoogleService $service): GoogleOAuthConnection
{
$client = $this->createClient($service);
$client->setRedirectUri(self::getRedirectUri());
$client = $this->buildClient();
$client->setScopes($service->scopes());
$client->fetchAccessTokenWithAuthCode($authorizationCode);
$token = $client->fetchAccessTokenWithAuthCode($code);
$tokenData = $client->getAccessToken();
if (isset($token['error'])) {
throw new \RuntimeException('Google OAuth error: ' . ($token['error_description'] ?? $token['error']));
if (isset($tokenData['error'])) {
throw new \RuntimeException('Google OAuth error: ' . ($tokenData['error_description'] ?? $tokenData['error']));
}
if (!isset($token['refresh_token'])) {
throw new \RuntimeException('No refresh token returned. Ensure access_type=offline and prompt=consent are set.');
}
$token = $tokenData['access_token'];
$refreshToken = $tokenData['refresh_token'] ?? null;
$expiresAt = now()->addSeconds($tokenData['expires_in']);
$clientId = $client->getClientId();
$clientSecret = $client->getClientSecret();
$email = $this->getEmailFromToken($token);
return GoogleOAuthConnection::updateOrCreate(
['service' => $service->value],
[
'client_id' => $clientId,
'client_secret' => $clientSecret,
'refresh_token' => $token['refresh_token'],
]
);
$connection = GoogleOAuthConnection::updateOrCreate([
'user_id' => auth()->id(),
'service' => $service->value,
'email' => $email,
], [
'access_token' => $token,
'refresh_token' => $refreshToken,
'expires_at' => $expiresAt,
]);
return $connection;
}
public function revoke(GoogleService $service): void
public function getAccessToken(GoogleOAuthConnection $connection): string
{
$connection = $this->getConnection($service);
if (!$connection) {
return;
$cacheKey = "google_oauth_token_{$connection->id}";
return Cache::remember($cacheKey, self::TOKEN_CACHE_TTL, function () use ($connection) {
if ($connection->isExpired()) {
$this->refreshToken($connection);
}
return $connection->access_token;
});
}
public function refreshToken(GoogleOAuthConnection $connection): void
{
if (!$connection->refresh_token) {
throw new \RuntimeException('Impossibile rinnovare: nessun refresh token');
}
$client = $this->createClient($service);
$client->revokeToken();
$client = $this->buildClient();
$client->fetchAccessTokenWithRefreshToken($connection->refresh_token);
$tokenData = $client->getAccessToken();
if (isset($tokenData['error'])) {
$connection->delete();
Cache::forget("google_oauth_token_{$connection->id}");
throw new \RuntimeException(
'Refresh token scaduto o revocato per ' . $connection->email
);
}
$connection->access_token = $tokenData['access_token'];
$connection->expires_at = now()->addSeconds($tokenData['expires_in']);
if (isset($tokenData['refresh_token'])) {
$connection->refresh_token = $tokenData['refresh_token'];
}
$connection->save();
Cache::forget("google_oauth_token_{$connection->id}");
}
public function revoke(GoogleOAuthConnection $connection): void
{
$client = $this->buildClient();
$client->revokeToken($connection->access_token);
$connection->delete();
Cache::forget("google_oauth_token_{$connection->id}");
}
public function getAccessToken(GoogleService $service): ?string
public function createXoAuth2SmtpTransport(
string $email,
string $accessToken
): \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport {
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
'smtp.gmail.com',
587,
true,
);
$transport->setUsername($email);
$transport->setPassword($accessToken);
$transport->setAuthenticators([
new \Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator(),
]);
return $transport;
}
private function getEmailFromToken(string $accessToken): string
{
$connection = $this->getConnection($service);
if (!$connection) {
return null;
$client = $this->buildClient();
$payload = $client->verifyIdToken($accessToken);
if ($payload && isset($payload['email'])) {
return $payload['email'];
}
try {
$client = $this->createClient($service);
$client->setAccessToken([
'access_token' => '',
'refresh_token' => $connection->refresh_token,
'client_id' => $connection->client_id,
'client_secret' => $connection->client_secret,
]);
$http = new \GuzzleHttp\Client();
$response = $http->get('https://www.googleapis.com/oauth2/v1/userinfo?alt=json', [
'headers' => ['Authorization' => "Bearer $accessToken"],
]);
$data = json_decode((string) $response->getBody(), true);
if ($client->isAccessTokenExpired()) {
$client->fetchAccessTokenWithRefreshToken($client->getRefreshToken());
$newToken = $client->getAccessToken();
if (isset($newToken['refresh_token'])) {
$connection->refresh_token = $newToken['refresh_token'];
$connection->save();
}
}
$accessToken = $client->getAccessToken();
return $accessToken['access_token'] ?? null;
} catch (\Exception $e) {
Log::error('Google OAuth token refresh failed for ' . $service->value . ': ' . $e->getMessage());
return null;
}
return $data['email'];
}
public function isAuthorized(GoogleService $service): bool
public function getConnectionStatus(): array
{
return $this->getConnection($service) !== null;
}
$connections = GoogleOAuthConnection::where('user_id', auth()->id())->get();
$status = [];
public function getConnection(GoogleService $service): ?GoogleOAuthConnection
{
return GoogleOAuthConnection::where('service', $service->value)->first();
}
public function buildClient(GoogleService $service): Client
{
$client = $this->createClient($service);
$connection = $this->getConnection($service);
if ($connection) {
$client->setAccessToken([
'access_token' => '',
'refresh_token' => $connection->refresh_token,
'client_id' => $connection->client_id,
'client_secret' => $connection->client_secret,
]);
if ($client->isAccessTokenExpired()) {
$client->fetchAccessTokenWithRefreshToken($client->getRefreshToken());
}
foreach (GoogleService::cases() as $service) {
$connection = $connections->firstWhere('service', $service->value);
$status[$service->value] = [
'connected' => $connection !== null,
'connection' => $connection,
'email' => $connection?->email,
'expired' => $connection?->isExpired() ?? false,
];
}
return $client;
return $status;
}
private function createClient(GoogleService $service): Client
{
$client = new Client();
$client->setApplicationName(config('app.name'));
$client->setScopes([$service->scope()]);
$client->setClientId(config('services.google.client_id'));
$client->setClientSecret(config('services.google.client_secret'));
$client->setRedirectUri(self::getRedirectUri());
return $client;
}
}
}
-62
View File
@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace App\Services;
use App\Enums\GoogleService;
use App\Models\StorageRepository;
use App\Services\GoogleOAuthService;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use League\Flysystem\Filesystem;
@@ -71,20 +69,6 @@ class StorageRepositoryService
private function checkGoogleDriveApiStatus(array $config): ?string
{
try {
try {
$oauth = app(GoogleOAuthService::class);
$connection = $oauth->getConnection(GoogleService::Drive);
if ($connection) {
$client = $oauth->buildClient(GoogleService::Drive);
$service = new \Google_Service_Drive($client);
$rootId = $config['root_folder_id'] ?? 'root';
$service->files->get($rootId, ['fields' => 'id']);
return null;
}
} catch (\Exception $e) {
// fall through to config-based auth
}
$client = new \Google_Client();
$client->setClientId($config['client_id']);
$client->setClientSecret($config['client_secret']);
@@ -260,18 +244,6 @@ class StorageRepositoryService
{
$config = $repo->getDecryptedConfig();
try {
$oauth = app(GoogleOAuthService::class);
$connection = $oauth->getConnection(GoogleService::Drive);
if ($connection) {
$client = $oauth->buildClient(GoogleService::Drive);
$service = new \Google_Service_Drive($client);
return $this->readGoogleDriveFileWithService($service, $config, $normalizedPath, $basename);
}
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::warning('Central Google Drive OAuth failed in readGoogleDriveFile, falling back: ' . $e->getMessage());
}
$client = new \Google_Client();
$client->setClientId($config['client_id']);
$client->setClientSecret($config['client_secret']);
@@ -311,40 +283,6 @@ class StorageRepositoryService
];
}
private function readGoogleDriveFileWithService(\Google_Service_Drive $service, array $config, string $normalizedPath, string $basename): array
{
$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 = [
+3 -6
View File
@@ -19,12 +19,9 @@ return Application::configure(basePath: dirname(__DIR__))
'permission' => CheckPermission::class,
]);
$middleware->trustProxies(at: '*');
$middleware->web(append: [
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
]);
// Web middleware defaults are already applied by the framework.
// Do NOT append EncryptCookies, StartSession, etc. here — they
// would run twice and corrupt session cookies, causing 419 errors.
})
->withExceptions(function (Exceptions $exceptions): void {
//
Regular → Executable
+227 -54
View File
@@ -20,6 +20,223 @@ echo "==> Pulizia cache..."
rm -rf bootstrap/cache/*.php storage/framework/cache/data/* storage/framework/sessions/* storage/framework/views/* storage/logs/* 2>/dev/null || true
echo " OK"
echo "==> Generazione post-deploy.sh..."
cat > post-deploy.sh << 'POSTDEPLOY'
#!/bin/bash
# post-deploy.sh — Generated by build-dist.sh
# Usage: bash post-deploy.sh {check|upgrade|install} [--yes]
set -u
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
ARTISAN="php ${APP_DIR}/artisan"
DIAGNOSE="php ${APP_DIR}/diagnose.php"
RED='\033[31m'; GREEN='\033[32m'; YELLOW='\033[33m'
CYAN='\033[36m'; BOLD='\033[1m'; NC='\033[0m'
MODE="${1:-check}"
BATCH=false; [ "${2:-}" = "--yes" ] && BATCH=true
echo -e "${CYAN}${BOLD}============================================${NC}"
echo -e "${CYAN}${BOLD} Glastree Post-Deploy — ${MODE}${NC}"
echo -e "${CYAN}${BOLD}============================================${NC}"
echo ""
IS_FRESH=false; [ ! -f "${APP_DIR}/.env" ] && IS_FRESH=true
log() { echo -e " ${BOLD}$1${NC}"; }
ok() { echo -e " ${GREEN}✅ $1${NC}"; }
warn() { echo -e " ${YELLOW}⚠ $1${NC}"; }
fail() { echo -e " ${RED}❌ $1${NC}"; }
check_artisan() {
if [ ! -f "${APP_DIR}/artisan" ]; then
fail "artisan non trovato. Esegui da root dell'applicazione."
exit 1
fi
}
run_step() {
local step="$1" desc="$2"; shift 2
log "${step} ${desc}"
if "$@" 2>&1 | sed 's/^/ /'; then
ok "${desc}"
return 0
else
local rc=$?
fail "${desc} — exit code ${rc}"
return ${rc}
fi
}
run_diagnose() {
if ${DIAGNOSE} 2>&1 | tail -5; then
ok "Diagnostica OK"
else
warn "Diagnostica ha rilevato problemi (non bloccante)"
fi
}
run_check() {
echo -e "${YELLOW}═══════════ CONTROLLO ═══════════${NC}" ; echo ""
echo -e "${BOLD}[0] Diagnostica sistema${NC}"
run_diagnose ; echo ""
echo -e "${BOLD}[1] Ambiente${NC}"
if $IS_FRESH; then warn ".env non trovato — fresh install necessario"
else ok ".env presente"
fi ; echo ""
echo -e "${BOLD}[2] Migration pendenti${NC}"
if [ -f "${APP_DIR}/artisan" ]; then
$ARTISAN migrate:status 2>&1 | grep -E '(Pending|Ran)' | head -5 || true
PENDING=$(php -r "echo trim(shell_exec('${ARTISAN} migrate:status 2>&1 | grep -c Pending || true'));" 2>/dev/null || echo "0")
if [ "${PENDING:-0}" -gt 0 ] 2>/dev/null; then
warn "${PENDING} migration da eseguire"
else
ok "Nessuna migration pendente"
fi
else
warn "artisan non disponibile — skip migration check"
fi ; echo ""
echo -e "${BOLD}[3] Storage & Permessi${NC}"
for d in storage/framework/cache/data storage/framework/sessions \
storage/framework/views storage/logs bootstrap/cache \
storage/app/public storage/app/backups; do
p="${APP_DIR}/${d}"
if [ -d "$p" ]; then
[ -w "$p" ] && ok "${d}" || warn "${d} — non scrivibile"
else
warn "${d} — mancante"
fi
done ; echo ""
echo -e "${BOLD}[4] Symlink storage${NC}"
if [ -L "${APP_DIR}/public/storage" ]; then
ok "public/storage → $(readlink "${APP_DIR}/public/storage")"
else
warn "public/storage non presente"
fi ; echo ""
echo -e "${BOLD}[5] Cache${NC}"
for d in bootstrap/cache storage/framework/cache/data \
storage/framework/sessions storage/framework/views; do
c=$(find "${APP_DIR}/${d}" -type f 2>/dev/null | wc -l)
[ "$c" -gt 0 ] && warn "${d}: ${c} file" || ok "${d}: vuoto"
done
echo "" ; echo -e "${YELLOW}═══════════ FINE CONTROLLO ═══════════${NC}"
}
run_upgrade() {
echo -e "${YELLOW}═══════════ UPGRADE ═══════════${NC}" ; check_artisan
run_step "[1/9]" "Directory storage" mkdir -p \
"${APP_DIR}/storage/framework/cache/data" \
"${APP_DIR}/storage/framework/sessions" \
"${APP_DIR}/storage/framework/views" \
"${APP_DIR}/storage/logs" "${APP_DIR}/bootstrap/cache" \
"${APP_DIR}/storage/app/public" \
"${APP_DIR}/storage/app/public/logos" \
"${APP_DIR}/storage/app/public/documenti/eventi" \
"${APP_DIR}/storage/app/backups" \
"${APP_DIR}/storage/app/documenti" \
"${APP_DIR}/storage/app/private/avatars/gruppi" || true
run_step "[2/9]" ".gitignore storage" bash -c \
"echo '*'>'${APP_DIR}/storage/app/public/.gitignore'; echo '!.gitignore'>>'${APP_DIR}/storage/app/public/.gitignore'" || true
run_step "[3/9]" "Permessi" chmod -R 775 \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
run_step "[4/9]" "Symlink storage" bash -c \
"rm -f '${APP_DIR}/public/storage'; ln -sf '../storage/app/public' '${APP_DIR}/public/storage'" || true
run_step "[5/10]" "Migrazioni DB" $ARTISAN migrate --force || true
run_step "[6/10]" "Seed diocesi" $ARTISAN db:seed --class=DiocesiSeeder --force || true
run_step "[7/10]" "Dump autoload" composer dump-autoload --no-dev --optimize || true
run_step "[8/10]" "Key generate" $ARTISAN key:generate --force || true
run_step "[9/10]" "Pulizia cache" bash -c \
"$ARTISAN config:clear && $ARTISAN route:clear && $ARTISAN view:clear" || true
run_step "[10/10]" "Verifica finale" $DIAGNOSE || true
echo "" ; echo -e "${GREEN}${BOLD}✅ Upgrade completato.${NC}"
}
run_install() {
echo -e "${YELLOW}═══════════ INSTALL (fresh) ═══════════${NC}" ; check_artisan
run_step "[1/10]" "Directory storage" mkdir -p \
"${APP_DIR}/storage/framework/cache/data" \
"${APP_DIR}/storage/framework/sessions" \
"${APP_DIR}/storage/framework/views" \
"${APP_DIR}/storage/logs" "${APP_DIR}/bootstrap/cache" \
"${APP_DIR}/storage/app/public" \
"${APP_DIR}/storage/app/public/logos" \
"${APP_DIR}/storage/app/public/documenti/eventi" \
"${APP_DIR}/storage/app/backups" \
"${APP_DIR}/storage/app/documenti" \
"${APP_DIR}/storage/app/private/avatars/gruppi" || true
run_step "[2/10]" ".gitignore storage" bash -c \
"echo '*'>'${APP_DIR}/storage/app/public/.gitignore'; echo '!.gitignore'>>'${APP_DIR}/storage/app/public/.gitignore'" || true
run_step "[3/10]" "Permessi" chmod -R 775 \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
run_step "[4/10]" "File .env" cp "${APP_DIR}/.env.example" "${APP_DIR}/.env" || true
warn "MODIFICA .env con i tuoi dati DB/URL prima di proseguire"
if ! $BATCH; then read -p " Fatto? (Invio per continuare) " _; fi
run_step "[5/10]" "Symlink storage" bash -c \
"rm -f '${APP_DIR}/public/storage'; ln -sf '../storage/app/public' '${APP_DIR}/public/storage'" || true
run_step "[6/10]" "Dump autoload" composer dump-autoload --no-dev --optimize || true
run_step "[7/10]" "Key generate" $ARTISAN key:generate --force || true
run_step "[8/11]" "Migrazioni DB" $ARTISAN migrate --force || true
run_step "[9/11]" "Seed diocesi" $ARTISAN db:seed --class=DiocesiSeeder --force || true
run_step "[10/11]" "Pulizia cache" bash -c \
"$ARTISAN config:clear && $ARTISAN route:clear && $ARTISAN view:clear" || true
run_step "[11/11]" "Verifica finale" $DIAGNOSE || true
echo "" ; echo -e "${GREEN}${BOLD}✅ Installazione completata.${NC}"
}
case "$MODE" in
check) run_check ;;
upgrade)
run_check ; echo ""
if $BATCH; then run_upgrade
else
read -p "Procedere con l'upgrade? (s/N) " c
[ "$c" = "s" ] || [ "$c" = "S" ] && run_upgrade || echo "Annullato."
fi
;;
install) run_install ;;
*)
echo "Uso: bash post-deploy.sh {check|upgrade|install} [--yes]"
echo " check — diagnostica (nessuna modifica)"
echo " upgrade — aggiorna installazione esistente"
echo " install — fresh install"
echo " --yes — batch (nessuna conferma)"
exit 1
;;
esac
POSTDEPLOY
chmod +x post-deploy.sh
echo " OK"
echo "==> Creazione archivio: ${ARCHIVE}"
tar czf "${ARCHIVE}" \
@@ -60,6 +277,8 @@ tar czf "${ARCHIVE}" \
--warning=no-file-changed \
.
rm -f post-deploy.sh
echo ""
echo "============================================"
echo " Archivio creato: ${ARCHIVE}"
@@ -70,60 +289,14 @@ 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 " # Upgrade di un'installazione esistente:"
echo " bash post-deploy.sh upgrade"
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 " # Fresh install:"
echo " bash post-deploy.sh install"
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 " # Solo controllo (nessuna modifica):"
echo " bash post-deploy.sh check"
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"
echo ""
echo "=== AGGIORNAMENTO (SERVER ESISTENTE) ==="
echo ""
echo " 1. BACKUP:"
echo " cp .env .env.backup.$(date +%Y%m%d_%H%M%S)"
echo " mysqldump -u USER -p DBNAME > backup_$(date +%Y%m%d_%H%M%S).sql"
echo ""
echo " 2. Estrai archivio (sovrascrive):"
echo " tar xzf ${ARCHIVE}"
echo ""
echo " 3. Ripristina .env e aggiungi nuove variabili:"
echo " cp .env.backup.* .env"
echo " # Aggiungi se mancanti:"
echo ' echo "GOOGLE_CLIENT_ID=" >> .env'
echo ' echo "GOOGLE_CLIENT_SECRET=" >> .env'
echo ""
echo " 4. Permessi:"
echo ' chmod -R 775 storage bootstrap/cache'
echo ' chown -R www-data:www-data storage bootstrap/cache'
echo ""
echo " 5. Ricrea symlink storage:"
echo ' rm -f public/storage'
echo ' ln -sf ../storage/app/public public/storage'
echo ""
echo " 6. Esegui migration DB:"
echo " php artisan migrate --force"
echo ""
echo " 7. Pulisci cache e ricostruisci asset:"
echo " php artisan config:clear"
echo " php artisan route:clear"
echo " php artisan view:clear"
echo " # npm install && npm run build (se serve ricostruire Vite)"
echo " # Batch mode (nessuna conferma):"
echo " bash post-deploy.sh upgrade --yes"
+2 -1
View File
@@ -35,9 +35,10 @@ return [
],
],
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URI', config('app.url') . '/auth/google/callback'),
],
];
@@ -8,6 +8,10 @@ return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('tenants')) {
return;
}
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('nome');
@@ -8,26 +8,30 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('diocesi', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('regione')->nullable();
$table->timestamps();
});
if (!Schema::hasTable('diocesi')) {
Schema::create('diocesi', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('regione')->nullable();
$table->timestamps();
});
}
Schema::create('comuni', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('codice_istat', 10)->nullable();
$table->string('cap')->nullable();
$table->string('sigla_provincia', 2)->nullable();
$table->string('regione')->nullable();
$table->float('latitudine')->nullable();
$table->float('longitudine')->nullable();
$table->timestamps();
$table->index('nome');
$table->index('sigla_provincia');
});
if (!Schema::hasTable('comuni')) {
Schema::create('comuni', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('codice_istat', 10)->nullable();
$table->string('cap')->nullable();
$table->string('sigla_provincia', 2)->nullable();
$table->string('regione')->nullable();
$table->float('latitudine')->nullable();
$table->float('longitudine')->nullable();
$table->timestamps();
$table->index('nome');
$table->index('sigla_provincia');
});
}
}
public function down(): void
@@ -8,27 +8,29 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('individui', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('codice_id', 5)->unique();
$table->string('cognome');
$table->string('nome');
$table->date('data_nascita')->nullable();
$table->string('indirizzo')->nullable();
$table->string('cap', 10)->nullable();
$table->string('città')->nullable();
$table->string('sigla_provincia', 2)->nullable();
$table->enum('genere', ['M', 'F'])->nullable();
$table->enum('tipo_documento', ['carta_identita', 'patente'])->nullable();
$table->date('scadenza_documento')->nullable();
$table->text('note')->nullable();
$table->string('avatar_path')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('codice_id');
$table->index('cognome');
});
if (!Schema::hasTable('individui')) {
Schema::create('individui', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('codice_id', 5)->unique();
$table->string('cognome');
$table->string('nome');
$table->date('data_nascita')->nullable();
$table->string('indirizzo')->nullable();
$table->string('cap', 10)->nullable();
$table->string('città')->nullable();
$table->string('sigla_provincia', 2)->nullable();
$table->enum('genere', ['M', 'F'])->nullable();
$table->enum('tipo_documento', ['carta_identita', 'patente'])->nullable();
$table->date('scadenza_documento')->nullable();
$table->text('note')->nullable();
$table->string('avatar_path')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('codice_id');
$table->index('cognome');
});
}
}
public function down(): void
@@ -8,16 +8,18 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('contatti', function (Blueprint $table) {
$table->id();
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->enum('tipo', ['telefono', 'cellulare', 'email', 'fax', 'web', 'telegram', 'whatsapp', 'altro']);
$table->string('valore');
$table->string('etichetta')->nullable();
$table->boolean('is_primary')->default(false);
$table->timestamps();
$table->index('individuo_id');
});
if (!Schema::hasTable('contatti')) {
Schema::create('contatti', function (Blueprint $table) {
$table->id();
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->enum('tipo', ['telefono', 'cellulare', 'email', 'fax', 'web', 'telegram', 'whatsapp', 'altro']);
$table->string('valore');
$table->string('etichetta')->nullable();
$table->boolean('is_primary')->default(false);
$table->timestamps();
$table->index('individuo_id');
});
}
}
public function down(): void
@@ -8,24 +8,26 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('gruppi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->foreignId('parent_id')->nullable()->constrained('gruppi')->onDelete('set null');
$table->foreignId('diocesi_id')->nullable()->constrained('diocesi')->onDelete('set null');
$table->foreignId('responsabile_id')->nullable()->constrained('individui')->onDelete('set null');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->string('indirizzo_incontro')->nullable();
$table->string('cap_incontro', 10)->nullable();
$table->string('città_incontro')->nullable();
$table->string('sigla_provincia_incontro', 2)->nullable();
$table->string('mappa_posizione')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('parent_id');
});
if (!Schema::hasTable('gruppi')) {
Schema::create('gruppi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->foreignId('parent_id')->nullable()->constrained('gruppi')->onDelete('set null');
$table->foreignId('diocesi_id')->nullable()->constrained('diocesi')->onDelete('set null');
$table->foreignId('responsabile_id')->nullable()->constrained('individui')->onDelete('set null');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->string('indirizzo_incontro')->nullable();
$table->string('cap_incontro', 10)->nullable();
$table->string('città_incontro')->nullable();
$table->string('sigla_provincia_incontro', 2)->nullable();
$table->string('mappa_posizione')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index('tenant_id');
$table->index('parent_id');
});
}
}
public function down(): void
@@ -8,15 +8,17 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('gruppo_individuo', function (Blueprint $table) {
$table->id();
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->string('ruolo_nel_gruppo')->nullable();
$table->date('data_adesione')->nullable();
$table->timestamps();
$table->unique(['gruppo_id', 'individuo_id']);
});
if (!Schema::hasTable('gruppo_individuo')) {
Schema::create('gruppo_individuo', function (Blueprint $table) {
$table->id();
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->string('ruolo_nel_gruppo')->nullable();
$table->date('data_adesione')->nullable();
$table->timestamps();
$table->unique(['gruppo_id', 'individuo_id']);
});
}
}
public function down(): void
@@ -8,35 +8,41 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('eventi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome_evento');
$table->string('tipo_evento')->nullable();
$table->enum('tipo_recorrenza', ['singolo', 'ricorrente'])->default('singolo');
$table->unsignedTinyInteger('giorno_settimana')->nullable();
$table->time('ora_inizio')->nullable();
$table->date('data_specifica')->nullable();
$table->unsignedInteger('durata_minuti')->nullable();
$table->text('descrizione')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->index('tenant_id');
});
if (!Schema::hasTable('eventi')) {
Schema::create('eventi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome_evento');
$table->string('tipo_evento')->nullable();
$table->enum('tipo_recorrenza', ['singolo', 'ricorrente'])->default('singolo');
$table->unsignedTinyInteger('giorno_settimana')->nullable();
$table->time('ora_inizio')->nullable();
$table->date('data_specifica')->nullable();
$table->unsignedInteger('durata_minuti')->nullable();
$table->text('descrizione')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->index('tenant_id');
});
}
Schema::create('eventi_gruppi', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->timestamps();
});
if (!Schema::hasTable('eventi_gruppi')) {
Schema::create('eventi_gruppi', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->timestamps();
});
}
Schema::create('eventi_responsabili', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->timestamps();
});
if (!Schema::hasTable('eventi_responsabili')) {
Schema::create('eventi_responsabili', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->timestamps();
});
}
}
public function down(): void
@@ -8,22 +8,24 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('documenti', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome_file');
$table->string('file_path');
$table->string('tipo')->nullable();
$table->enum('tipologia', ['avatar', 'galleria', 'documento', 'statuto', 'altro'])->default('documento');
$table->enum('visibilita', ['pubblico', 'gruppo', 'individuo', 'associazione', 'federazione'])->default('gruppo');
$table->unsignedBigInteger('visibilita_target_id')->nullable();
$table->string('visibilita_target_type')->nullable();
$table->string('mime_type')->nullable();
$table->unsignedBigInteger('dimensione')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->index('tenant_id');
});
if (!Schema::hasTable('documenti')) {
Schema::create('documenti', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome_file');
$table->string('file_path')->nullable();
$table->string('tipo')->nullable();
$table->string('tipologia', 50)->default('documento');
$table->string('visibilita', 50)->default('gruppo');
$table->unsignedBigInteger('visibilita_target_id')->nullable();
$table->string('visibilita_target_type')->nullable();
$table->string('mime_type')->nullable();
$table->unsignedBigInteger('dimensione')->nullable();
$table->text('note')->nullable();
$table->timestamps();
$table->index('tenant_id');
});
}
}
public function down(): void
@@ -8,43 +8,49 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('mailing_lists', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->boolean('attiva')->default(true);
$table->timestamps();
$table->index('tenant_id');
});
if (!Schema::hasTable('mailing_lists')) {
Schema::create('mailing_lists', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->boolean('attiva')->default(true);
$table->timestamps();
$table->index('tenant_id');
});
}
Schema::create('mailing_contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('mailing_list_id')->constrained('mailing_lists')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->boolean('opt_in')->default(true);
$table->dateTime('opt_in_data')->nullable();
$table->dateTime('opt_out_data')->nullable();
$table->timestamps();
$table->unique(['mailing_list_id', 'individuo_id']);
});
if (!Schema::hasTable('mailing_contacts')) {
Schema::create('mailing_contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('mailing_list_id')->constrained('mailing_lists')->onDelete('cascade');
$table->foreignId('individuo_id')->constrained('individui')->onDelete('cascade');
$table->boolean('opt_in')->default(true);
$table->dateTime('opt_in_data')->nullable();
$table->dateTime('opt_out_data')->nullable();
$table->timestamps();
$table->unique(['mailing_list_id', 'individuo_id']);
});
}
Schema::create('mailing_messaggi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->foreignId('mailing_list_id')->nullable()->constrained('mailing_lists')->onDelete('set null');
$table->unsignedBigInteger('user_id')->nullable();
$table->string('oggetto');
$table->text('corpo')->nullable();
$table->enum('canale', ['email', 'telegram'])->default('email');
$table->enum('stato', ['bozza', 'in_coda', 'inviato', 'fallito'])->default('bozza');
$table->timestamp('inviato_al')->nullable();
$table->integer('totale_destinatari')->default(0);
$table->integer('invii_ok')->default(0);
$table->integer('invii_falliti')->default(0);
$table->timestamps();
$table->index('tenant_id');
});
if (!Schema::hasTable('mailing_messaggi')) {
Schema::create('mailing_messaggi', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->foreignId('mailing_list_id')->nullable()->constrained('mailing_lists')->onDelete('set null');
$table->unsignedBigInteger('user_id')->nullable();
$table->string('oggetto');
$table->text('corpo')->nullable();
$table->enum('canale', ['email', 'telegram'])->default('email');
$table->enum('stato', ['bozza', 'in_coda', 'inviato', 'fallito'])->default('bozza');
$table->timestamp('inviato_al')->nullable();
$table->integer('totale_destinatari')->default(0);
$table->integer('invii_ok')->default(0);
$table->integer('invii_falliti')->default(0);
$table->timestamps();
$table->index('tenant_id');
});
}
}
public function down(): void
@@ -8,18 +8,20 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('notifiche', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('tipo');
$table->string('titolo');
$table->text('messaggio');
$table->string('link')->nullable();
$table->boolean('is_read')->default(false);
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
if (!Schema::hasTable('notifiche')) {
Schema::create('notifiche', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('tipo');
$table->string('titolo');
$table->text('messaggio');
$table->string('link')->nullable();
$table->boolean('is_read')->default(false);
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
}
}
public function down(): void
@@ -8,34 +8,40 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->boolean('is_admin')->default(false);
$table->rememberToken();
$table->timestamps();
$table->index('tenant_id');
$table->index('email');
});
if (!Schema::hasTable('users')) {
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null');
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->boolean('is_admin')->default(false);
$table->rememberToken();
$table->timestamps();
$table->index('tenant_id');
$table->index('email');
});
}
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
if (!Schema::hasTable('password_reset_tokens')) {
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
if (!Schema::hasTable('sessions')) {
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
}
public function down(): void
@@ -8,50 +8,60 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
if (!Schema::hasTable('cache')) {
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
}
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
if (!Schema::hasTable('cache_locks')) {
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
if (!Schema::hasTable('jobs')) {
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
if (!Schema::hasTable('job_batches')) {
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
}
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at');
});
if (!Schema::hasTable('failed_jobs')) {
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at');
});
}
}
public function down(): void
@@ -8,12 +8,14 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('eventi_documenti', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('documento_id')->constrained('documenti')->onDelete('cascade');
$table->timestamps();
});
if (!Schema::hasTable('eventi_documenti')) {
Schema::create('eventi_documenti', function (Blueprint $table) {
$table->id();
$table->foreignId('evento_id')->constrained('eventi')->onDelete('cascade');
$table->foreignId('documento_id')->constrained('documenti')->onDelete('cascade');
$table->timestamps();
});
}
}
public function down(): void
@@ -8,19 +8,21 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('viste_report', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('nome');
$table->string('tipo'); // individui, gruppi, documenti, eventi
$table->json('colonne_visibili')->nullable();
$table->json('colonne_ordinamento')->nullable(); // [['colonna', 'direzione']]
$table->json('filtri')->nullable(); // [['colonna', 'operatore', 'valore']]
$table->string('ricerca')->nullable();
$table->timestamps();
$table->index('tipo');
$table->index('user_id');
});
if (!Schema::hasTable('viste_report')) {
Schema::create('viste_report', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('nome');
$table->string('tipo'); // individui, gruppi, documenti, eventi
$table->json('colonne_visibili')->nullable();
$table->json('colonne_ordinamento')->nullable(); // [['colonna', 'direzione']]
$table->json('filtri')->nullable(); // [['colonna', 'operatore', 'valore']]
$table->string('ricerca')->nullable();
$table->timestamps();
$table->index('tipo');
$table->index('user_id');
});
}
}
public function down(): void
@@ -8,25 +8,27 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('tipologie_documenti', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
if (!Schema::hasTable('tipologie_documenti')) {
Schema::create('tipologie_documenti', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
$defaultTipologie = ['avatar', 'galleria', 'documento', 'statuto', 'altro'];
foreach ($defaultTipologie as $i => $nome) {
DB::table('tipologie_documenti')->insert([
'nome' => $nome,
'descrizione' => ucfirst($nome),
'ordine' => $i,
'attiva' => true,
'created_at' => now(),
'updated_at' => now(),
]);
$defaultTipologie = ['avatar', 'galleria', 'documento', 'statuto', 'altro'];
foreach ($defaultTipologie as $i => $nome) {
DB::table('tipologie_documenti')->insert([
'nome' => $nome,
'descrizione' => ucfirst($nome),
'ordine' => $i,
'attiva' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}
@@ -14,28 +14,32 @@ return new class extends Migration
});
}
Schema::create('activity_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('action');
$table->string('module');
$table->string('description')->nullable();
$table->json('details')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamp('created_at')->useCurrent();
if (!Schema::hasTable('activity_logs')) {
Schema::create('activity_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('action');
$table->string('module');
$table->string('description')->nullable();
$table->json('details')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['user_id', 'module']);
$table->index(['created_at']);
$table->index(['action']);
});
}
$table->index(['user_id', 'module']);
$table->index(['created_at']);
$table->index(['action']);
});
Schema::create('role_presets', function (Blueprint $table) {
$table->id();
$table->string('name', 50)->unique();
$table->string('description')->nullable();
$table->json('permissions');
$table->timestamps();
});
if (!Schema::hasTable('role_presets')) {
Schema::create('role_presets', function (Blueprint $table) {
$table->id();
$table->string('name', 50)->unique();
$table->string('description')->nullable();
$table->json('permissions');
$table->timestamps();
});
}
}
public function down(): void
@@ -23,96 +23,106 @@ return new class extends Migration
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id
$table->string('name');
$table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
if (!Schema::hasTable($tableNames['permissions'])) {
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id
$table->string('name');
$table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
}
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
if (!Schema::hasTable($tableNames['roles'])) {
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name');
$table->string('guard_name');
$table->timestamps();
if ($teams || config('permission.testing')) {
}
$table->string('name');
$table->string('guard_name');
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
} else {
$table->unique(['name', 'guard_name']);
}
});
}
});
}
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
if (!Schema::hasTable($tableNames['model_has_permissions'])) {
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
if ($teams) {
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
}
});
}
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
if (!Schema::hasTable($tableNames['model_has_roles'])) {
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
if ($teams) {
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
}
});
}
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
if (!Schema::hasTable($tableNames['role_has_permissions'])) {
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
$table->foreign($pivotRole)
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
}
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
@@ -8,26 +8,28 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('email_settings', function (Blueprint $table) {
$table->id();
$table->string('imap_host')->nullable();
$table->integer('imap_port')->nullable()->default(993);
$table->string('imap_encryption')->nullable()->default('ssl');
$table->string('imap_username')->nullable();
$table->string('imap_password')->nullable();
$table->string('smtp_host')->nullable();
$table->integer('smtp_port')->nullable()->default(587);
$table->string('smtp_encryption')->nullable()->default('tls');
$table->string('smtp_username')->nullable();
$table->string('smtp_password')->nullable();
$table->string('email_address')->nullable();
$table->string('email_name')->nullable();
$table->string('reply_to')->nullable();
$table->integer('sync_interval_minutes')->default(5);
$table->timestamp('last_sync_at')->nullable();
$table->boolean('is_active')->default(false);
$table->timestamps();
});
if (!Schema::hasTable('email_settings')) {
Schema::create('email_settings', function (Blueprint $table) {
$table->id();
$table->string('imap_host')->nullable();
$table->integer('imap_port')->nullable()->default(993);
$table->string('imap_encryption')->nullable()->default('ssl');
$table->string('imap_username')->nullable();
$table->string('imap_password')->nullable();
$table->string('smtp_host')->nullable();
$table->integer('smtp_port')->nullable()->default(587);
$table->string('smtp_encryption')->nullable()->default('tls');
$table->string('smtp_username')->nullable();
$table->string('smtp_password')->nullable();
$table->string('email_address')->nullable();
$table->string('email_name')->nullable();
$table->string('reply_to')->nullable();
$table->integer('sync_interval_minutes')->default(5);
$table->timestamp('last_sync_at')->nullable();
$table->boolean('is_active')->default(false);
$table->timestamps();
});
}
}
public function down(): void
@@ -8,17 +8,19 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('email_folders', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('imap_folder_name')->nullable();
$table->string('type')->default('custom');
$table->string('icon')->default('fa-folder');
$table->string('color')->nullable();
$table->integer('sort_order')->default(0);
$table->boolean('is_system')->default(false);
$table->timestamps();
});
if (!Schema::hasTable('email_folders')) {
Schema::create('email_folders', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('imap_folder_name')->nullable();
$table->string('type')->default('custom');
$table->string('icon')->default('fa-folder');
$table->string('color')->nullable();
$table->integer('sort_order')->default(0);
$table->boolean('is_system')->default(false);
$table->timestamps();
});
}
}
public function down(): void
@@ -8,35 +8,37 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('email_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('email_folder_id')->constrained('email_folders')->onDelete('cascade');
$table->string('message_id')->nullable()->index();
$table->string('subject')->nullable();
$table->string('from_name')->nullable();
$table->string('from_email')->nullable();
$table->string('to_name')->nullable();
$table->string('to_email')->nullable();
$table->text('cc')->nullable();
$table->text('bcc')->nullable();
$table->longText('body_text')->nullable();
$table->longText('body_html')->nullable();
$table->boolean('is_read')->default(false);
$table->boolean('is_starred')->default(false);
$table->boolean('is_important')->default(false);
$table->boolean('is_sent')->default(false);
$table->boolean('is_draft')->default(false);
$table->boolean('is_trash')->default(false);
$table->timestamp('received_at')->nullable();
$table->timestamp('sent_at')->nullable();
$table->unsignedBigInteger('imap_uid')->nullable();
$table->timestamps();
$table->index('is_read');
$table->index('is_starred');
$table->index('is_sent');
$table->index('received_at');
});
if (!Schema::hasTable('email_messages')) {
Schema::create('email_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('email_folder_id')->constrained('email_folders')->onDelete('cascade');
$table->string('message_id')->nullable()->index();
$table->string('subject')->nullable();
$table->string('from_name')->nullable();
$table->string('from_email')->nullable();
$table->string('to_name')->nullable();
$table->string('to_email')->nullable();
$table->text('cc')->nullable();
$table->text('bcc')->nullable();
$table->longText('body_text')->nullable();
$table->longText('body_html')->nullable();
$table->boolean('is_read')->default(false);
$table->boolean('is_starred')->default(false);
$table->boolean('is_important')->default(false);
$table->boolean('is_sent')->default(false);
$table->boolean('is_draft')->default(false);
$table->boolean('is_trash')->default(false);
$table->timestamp('received_at')->nullable();
$table->timestamp('sent_at')->nullable();
$table->unsignedBigInteger('imap_uid')->nullable();
$table->timestamps();
$table->index('is_read');
$table->index('is_starred');
$table->index('is_sent');
$table->index('received_at');
});
}
}
public function down(): void
@@ -1,22 +1,19 @@
<?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->enum('tipologia', ['avatar', 'galleria', 'documento', 'statuto', 'altro', 'email_attachment'])->default('documento')->change();
});
// Column type changed to string in 2024_01_01_000008_create_documenti_table.php.
// Application-level validation handles allowed values.
}
public function down(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->enum('tipologia', ['avatar', 'galleria', 'documento', 'statuto', 'altro'])->default('documento')->change();
});
// No-op: column is string, not enum.
}
};
@@ -7,14 +7,15 @@ return new class extends Migration
{
public function up(): void
{
DB::table('tipologie_documenti')->insert([
'nome' => 'email_attachment',
'descrizione' => 'Allegato email',
'ordine' => 100,
'attiva' => true,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('tipologie_documenti')->updateOrInsert(
['nome' => 'email_attachment'],
[
'descrizione' => 'Allegato email',
'ordine' => 100,
'attiva' => true,
'updated_at' => now(),
]
);
}
public function down(): void
@@ -8,17 +8,19 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('email_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('email_message_id')->constrained('email_messages')->onDelete('cascade');
$table->string('filename');
$table->string('mime_type')->nullable();
$table->bigInteger('size')->default(0);
$table->string('file_path');
$table->boolean('is_saved_to_documenti')->default(false);
$table->foreignId('documenti_id')->nullable()->constrained('documenti')->onDelete('set null');
$table->timestamps();
});
if (!Schema::hasTable('email_attachments')) {
Schema::create('email_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('email_message_id')->constrained('email_messages')->onDelete('cascade');
$table->string('filename');
$table->string('mime_type')->nullable();
$table->bigInteger('size')->default(0);
$table->string('file_path');
$table->boolean('is_saved_to_documenti')->default(false);
$table->foreignId('documenti_id')->nullable()->constrained('documenti')->onDelete('set null');
$table->timestamps();
});
}
}
public function down(): void
@@ -8,34 +8,36 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('ruoli', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
if (!Schema::hasTable('ruoli')) {
Schema::create('ruoli', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
$defaultRuoli = [
['nome' => 'Membro', 'descrizione' => 'Membro del gruppo'],
['nome' => 'Catechista', 'descrizione' => 'Catechista del gruppo'],
['nome' => 'Animatore', 'descrizione' => 'Animatore del gruppo'],
['nome' => 'Responsabile', 'descrizione' => 'Responsabile del gruppo'],
['nome' => 'Vice Responsabile', 'descrizione' => 'Vice responsabile del gruppo'],
['nome' => 'Segretario', 'descrizione' => 'Segretario del gruppo'],
['nome' => 'Tesoriere', 'descrizione' => 'Tesoriere del gruppo'],
];
$defaultRuoli = [
['nome' => 'Membro', 'descrizione' => 'Membro del gruppo'],
['nome' => 'Catechista', 'descrizione' => 'Catechista del gruppo'],
['nome' => 'Animatore', 'descrizione' => 'Animatore del gruppo'],
['nome' => 'Responsabile', 'descrizione' => 'Responsabile del gruppo'],
['nome' => 'Vice Responsabile', 'descrizione' => 'Vice responsabile del gruppo'],
['nome' => 'Segretario', 'descrizione' => 'Segretario del gruppo'],
['nome' => 'Tesoriere', 'descrizione' => 'Tesoriere del gruppo'],
];
foreach ($defaultRuoli as $i => $ruolo) {
DB::table('ruoli')->insert([
'nome' => $ruolo['nome'],
'descrizione' => $ruolo['descrizione'],
'ordine' => $i,
'attiva' => true,
'created_at' => now(),
'updated_at' => now(),
]);
foreach ($defaultRuoli as $i => $ruolo) {
DB::table('ruoli')->insert([
'nome' => $ruolo['nome'],
'descrizione' => $ruolo['descrizione'],
'ordine' => $i,
'attiva' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_id')) {
@@ -8,10 +8,13 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->dropForeign(['ruolo_id']);
$table->dropColumn('ruolo_id');
});
if (Schema::hasColumn('gruppo_individuo', 'ruolo_id')) {
Schema::table('gruppo_individuo', function (Blueprint $table) {
$table->dropIndex(['ruolo_id']);
$table->dropForeign(['ruolo_id']);
$table->dropColumn('ruolo_id');
});
}
if (!Schema::hasColumn('gruppo_individuo', 'ruolo_ids')) {
Schema::table('gruppo_individuo', function (Blueprint $table) {
@@ -1,17 +1,19 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement("ALTER TABLE documenti MODIFY COLUMN tipologia ENUM('avatar', 'galleria', 'documento', 'statuto', 'altro', 'programma', 'locandina') DEFAULT 'documento'");
// Column type changed to string in 2024_01_01_000008_create_documenti_table.php.
// Application-level validation handles allowed values.
}
public function down(): void
{
DB::statement("ALTER TABLE documenti MODIFY COLUMN tipologia ENUM('avatar', 'galleria', 'documento', 'statuto', 'altro') DEFAULT 'documento'");
// No-op: column is string, not enum.
}
};
@@ -8,15 +8,17 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('report_custom', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->string('tipo_report');
$table->json('config')->nullable();
$table->timestamps();
});
if (!Schema::hasTable('report_custom')) {
Schema::create('report_custom', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->onDelete('cascade');
$table->string('nome');
$table->text('descrizione')->nullable();
$table->string('tipo_report');
$table->json('config')->nullable();
$table->timestamps();
});
}
}
public function down(): void
@@ -10,25 +10,27 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('tipologie_eventi', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
if (!Schema::hasTable('tipologie_eventi')) {
Schema::create('tipologie_eventi', function (Blueprint $table) {
$table->id();
$table->string('nome', 50)->unique();
$table->string('descrizione', 255)->nullable();
$table->integer('ordine')->default(0);
$table->boolean('attiva')->default(true);
$table->timestamps();
});
DB::table('tipologie_eventi')->insert([
['nome' => 'catechesi', 'descrizione' => 'Catechesi', 'ordine' => 0, 'attiva' => true],
['nome' => 'liturgia', 'descrizione' => 'Liturgia', 'ordine' => 1, 'attiva' => true],
['nome' => 'animazione', 'descrizione' => 'Animazione', 'ordine' => 2, 'attiva' => true],
['nome' => 'formazione', 'descrizione' => 'Formazione', 'ordine' => 3, 'attiva' => true],
['nome' => 'incontro', 'descrizione' => 'Incontro', 'ordine' => 4, 'attiva' => true],
['nome' => 'riunione', 'descrizione' => 'Riunione', 'ordine' => 5, 'attiva' => true],
['nome' => 'festa', 'descrizione' => 'Festa / Sagra', 'ordine' => 6, 'attiva' => true],
['nome' => 'altro', 'descrizione' => 'Altro', 'ordine' => 7, 'attiva' => true],
]);
DB::table('tipologie_eventi')->insert([
['nome' => 'catechesi', 'descrizione' => 'Catechesi', 'ordine' => 0, 'attiva' => true],
['nome' => 'liturgia', 'descrizione' => 'Liturgia', 'ordine' => 1, 'attiva' => true],
['nome' => 'animazione', 'descrizione' => 'Animazione', 'ordine' => 2, 'attiva' => true],
['nome' => 'formazione', 'descrizione' => 'Formazione', 'ordine' => 3, 'attiva' => true],
['nome' => 'incontro', 'descrizione' => 'Incontro', 'ordine' => 4, 'attiva' => true],
['nome' => 'riunione', 'descrizione' => 'Riunione', 'ordine' => 5, 'attiva' => true],
['nome' => 'festa', 'descrizione' => 'Festa / Sagra', 'ordine' => 6, 'attiva' => true],
['nome' => 'altro', 'descrizione' => 'Altro', 'ordine' => 7, 'attiva' => true],
]);
}
}
public function down(): void
@@ -10,21 +10,23 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('sender_accounts', function (Blueprint $table) {
$table->id();
$table->string('email_address')->unique();
$table->string('email_name')->nullable();
$table->string('smtp_host')->nullable();
$table->integer('smtp_port')->default(587);
$table->string('smtp_encryption')->default('tls');
$table->string('smtp_username')->nullable();
$table->text('smtp_password')->nullable();
$table->string('reply_to')->nullable();
$table->string('verify_email')->nullable();
$table->text('note')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
if (!Schema::hasTable('sender_accounts')) {
Schema::create('sender_accounts', function (Blueprint $table) {
$table->id();
$table->string('email_address')->unique();
$table->string('email_name')->nullable();
$table->string('smtp_host')->nullable();
$table->integer('smtp_port')->default(587);
$table->string('smtp_encryption')->default('tls');
$table->string('smtp_username')->nullable();
$table->text('smtp_password')->nullable();
$table->string('reply_to')->nullable();
$table->string('verify_email')->nullable();
$table->text('note')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
}
public function down(): void
@@ -8,12 +8,14 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('documenti_cartelle', function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')->nullable()->constrained('documenti_cartelle')->onDelete('cascade');
$table->string('nome');
$table->timestamps();
});
if (!Schema::hasTable('documenti_cartelle')) {
Schema::create('documenti_cartelle', function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')->nullable()->constrained('documenti_cartelle')->onDelete('cascade');
$table->string('nome');
$table->timestamps();
});
}
}
public function down(): void
@@ -8,15 +8,17 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('storage_repositories', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('tipo', 50);
$table->text('config');
$table->boolean('is_active')->default(true);
$table->integer('ordine')->default(0);
$table->timestamps();
});
if (!Schema::hasTable('storage_repositories')) {
Schema::create('storage_repositories', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('tipo', 50);
$table->text('config');
$table->boolean('is_active')->default(true);
$table->integer('ordine')->default(0);
$table->timestamps();
});
}
}
public function down(): void
@@ -10,21 +10,23 @@ 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();
});
if (!Schema::hasTable('calendario_connessioni')) {
Schema::create('calendario_connessioni', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->string('tipo'); // google_calendar, caldav
$table->text('config'); // JSON
$table->string('stato')->default('disconnesso'); // connesso, disconnesso, errore
$table->string('sync_direction')->default('bidirectional'); // import, export, bidirectional
$table->integer('sync_interval_minutes')->default(60);
$table->timestamp('last_sync_at')->nullable();
$table->timestamp('last_error_at')->nullable();
$table->text('last_error_message')->nullable();
$table->boolean('is_active')->default(true);
$table->integer('ordine')->default(0);
$table->timestamps();
});
}
}
public function down(): void
@@ -18,7 +18,7 @@ return new class extends Migration
$table->string('color', 7)->nullable();
$table->integer('order_column')->default(0);
$table->timestamps();
});
});
}
if (!Schema::hasTable('taggables')) {
@@ -26,7 +26,7 @@ return new class extends Migration
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->morphs('taggable');
$table->primary(['tag_id', 'taggable_id', 'taggable_type']);
});
});
}
}
@@ -1,24 +0,0 @@
<?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('email_settings', function (Blueprint $table) {
$table->string('auth_method', 20)->default('password');
});
}
public function down(): void
{
Schema::table('email_settings', function (Blueprint $table) {
$table->dropColumn('auth_method');
});
}
};
@@ -0,0 +1,35 @@
<?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('google_oauth_connections')) {
return;
}
Schema::create('google_oauth_connections', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('service'); // email, drive, calendar
$table->string('email');
$table->text('access_token');
$table->text('refresh_token')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'service', 'email']);
});
}
public function down(): void
{
Schema::dropIfExists('google_oauth_connections');
}
};
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasColumn('email_settings', 'auth_method')) {
Schema::table('email_settings', function (Blueprint $table) {
$table->string('auth_method', 20)->default('password');
$table->foreignId('google_oauth_connection_id')
->nullable()
->constrained('google_oauth_connections')
->nullOnDelete();
});
}
if (!Schema::hasColumn('sender_accounts', 'auth_method')) {
Schema::table('sender_accounts', function (Blueprint $table) {
$table->string('auth_method', 20)->default('password');
$table->foreignId('google_oauth_connection_id')
->nullable()
->constrained('google_oauth_connections')
->nullOnDelete();
});
}
}
public function down(): void
{
Schema::table('email_settings', function (Blueprint $table) {
if (Schema::hasColumn('email_settings', 'google_oauth_connection_id')) {
$table->dropForeign(['google_oauth_connection_id']);
}
if (Schema::hasColumn('email_settings', 'auth_method')) {
$table->dropColumn(['auth_method', 'google_oauth_connection_id']);
}
});
Schema::table('sender_accounts', function (Blueprint $table) {
if (Schema::hasColumn('sender_accounts', 'google_oauth_connection_id')) {
$table->dropForeign(['google_oauth_connection_id']);
}
if (Schema::hasColumn('sender_accounts', 'auth_method')) {
$table->dropColumn(['auth_method', 'google_oauth_connection_id']);
}
});
}
};
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('email_settings')) {
return;
}
if (!Schema::hasColumn('email_settings', 'from_email')) {
Schema::table('email_settings', function (Blueprint $table) {
$table->string('from_email')->nullable()->after('email_name');
});
}
if (!Schema::hasColumn('email_settings', 'from_name')) {
Schema::table('email_settings', function (Blueprint $table) {
$table->string('from_name')->nullable()->after('from_email');
});
}
}
public function down(): void
{
Schema::table('email_settings', function (Blueprint $table) {
$table->dropColumn(['from_email', 'from_name']);
});
}
};
@@ -0,0 +1,31 @@
<?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('gruppo_contatti')) {
Schema::create('gruppo_contatti', function (Blueprint $table) {
$table->id();
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->string('tipo');
$table->string('valore');
$table->string('etichetta')->nullable();
$table->boolean('is_primary')->default(false);
$table->timestamps();
$table->index('gruppo_id');
});
}
}
public function down(): void
{
Schema::dropIfExists('gruppo_contatti');
}
};
@@ -0,0 +1,27 @@
<?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('diocesi_gruppo')) {
Schema::create('diocesi_gruppo', function (Blueprint $table) {
$table->id();
$table->foreignId('gruppo_id')->constrained('gruppi')->onDelete('cascade');
$table->foreignId('diocesi_id')->constrained('diocesi')->onDelete('cascade');
$table->unique(['gruppo_id', 'diocesi_id']);
});
}
}
public function down(): void
{
Schema::dropIfExists('diocesi_gruppo');
}
};
@@ -10,18 +10,18 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('google_oauth_connections', function (Blueprint $table) {
Schema::create('firme', function (Blueprint $table) {
$table->id();
$table->string('service', 20)->unique();
$table->string('client_id', 255);
$table->text('client_secret');
$table->text('refresh_token');
$table->foreignId('email_setting_id')->constrained()->cascadeOnDelete();
$table->string('nome', 255);
$table->longText('contenuto_html')->nullable();
$table->boolean('is_default')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('google_oauth_connections');
Schema::dropIfExists('firme');
}
};
};
@@ -0,0 +1,25 @@
<?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_lists', function (Blueprint $table) {
$table->foreignId('firma_id')->nullable()->constrained('firme')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('mailing_lists', function (Blueprint $table) {
$table->dropForeign(['firma_id']);
$table->dropColumn('firma_id');
});
}
};
@@ -0,0 +1,25 @@
<?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->foreignId('firma_id')->nullable()->constrained('firme')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('mailing_messaggi', function (Blueprint $table) {
$table->dropForeign(['firma_id']);
$table->dropColumn('firma_id');
});
}
};
@@ -0,0 +1,29 @@
<?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('sessions')) {
Schema::create('sessions', function (Blueprint $table): void {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
}
public function down(): void
{
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,25 @@
<?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_lists', function (Blueprint $table) {
$table->foreignId('sender_account_id')->nullable()->constrained('sender_accounts')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('mailing_lists', function (Blueprint $table) {
$table->dropForeign(['sender_account_id']);
$table->dropColumn('sender_account_id');
});
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (!Schema::hasColumn('gruppi', 'anno_fondazione')) {
Schema::table('gruppi', function (Blueprint $table) {
$table->year('anno_fondazione')->nullable()->after('nome');
});
}
}
public function down(): void
{
if (Schema::hasColumn('gruppi', 'anno_fondazione')) {
Schema::table('gruppi', function (Blueprint $table) {
$table->dropColumn('anno_fondazione');
});
}
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->text('url')->nullable()->after('note');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('documenti', function (Blueprint $table) {
$table->dropColumn('url');
});
}
};
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasColumn('documenti', 'file_path') && DB::getDriverName() === 'mysql') {
DB::statement('ALTER TABLE documenti MODIFY COLUMN file_path VARCHAR(255) NULL');
}
}
public function down(): void
{
if (Schema::hasColumn('documenti', 'file_path') && DB::getDriverName() === 'mysql') {
DB::statement('ALTER TABLE documenti MODIFY COLUMN file_path VARCHAR(255) NOT NULL');
}
}
};
+1 -1
View File
@@ -68,7 +68,7 @@ class ComuniSeeder extends Seeder
];
foreach ($comuni as $c) {
Comune::create($c);
Comune::firstOrCreate(['codice_istat' => $c['codice_istat']], $c);
}
}
}

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