diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 00000000..0646999b --- /dev/null +++ b/.phpunit.result.cache @@ -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}} \ No newline at end of file diff --git a/MEMORY.md b/MEMORY.md index 827f5efd..78e5de42 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -42,6 +42,19 @@ App gestionale Laravel 13 con AdminLTE 4 per gestione Persone e Gruppi. - `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 @@ -331,8 +344,7 @@ if (!empty($contatto['individuo_id'])) { create } ``` ## Prossimi Passi -- [DONE] ... (existing items) -- Verificare end-to-end su remote: tutte le nuove mass action, mailing list con contatti senza email, ricerca multi-tag +- [DONE] 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` @@ -1115,3 +1127,47 @@ Fasi install (9 step): come upgrade + setup .env con pausa interattiva - 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 diff --git a/app/Http/Controllers/DocumentoController.php b/app/Http/Controllers/DocumentoController.php index 6f1437a1..80565f8e 100644 --- a/app/Http/Controllers/DocumentoController.php +++ b/app/Http/Controllers/DocumentoController.php @@ -201,7 +201,7 @@ class DocumentoController extends Controller $tipologieValidi = TipologiaDocumento::opzioni(); $tipologieRule = 'in:' . implode(',', $tipologieValidi); - $data = $request->validate([ + $rules = [ 'nome_file' => 'required|string|max:255', 'tipologia' => 'required|' . $tipologieRule, 'contesto_tipo' => 'nullable|in:individuo,gruppo,evento,mailing', @@ -209,7 +209,13 @@ class DocumentoController extends Controller 'note' => 'nullable|string', 'tags' => 'nullable|array', 'tags.*' => 'exists:tags,id', - ]); + ]; + + if ($documento->isLink()) { + $rules['url'] = 'nullable|url|max:2048'; + } + + $data = $request->validate($rules); $contestoTipo = $data['contesto_tipo'] ?? null; $data['visibilita'] = $contestoTipo ?: 'pubblico'; @@ -228,6 +234,10 @@ class DocumentoController extends Controller $data['visibilita_target_type'] = null; } + if ($documento->isLink()) { + $data['url'] = $data['url'] ?? $documento->url; + } + $documento->update($data); if ($request->has('tags')) { @@ -246,10 +256,11 @@ class DocumentoController extends Controller $tipologieValidi = TipologiaDocumento::opzioni(); $tipologieRule = 'in:' . implode(',', $tipologieValidi); - $data = $request->validate([ + $isLink = $request->input('tipo_caricamento') === 'link'; + + $rules = [ 'nome_file' => 'required|string|max:255', 'tipologia' => 'required|' . $tipologieRule, - 'file' => 'required|file|max:10240', 'visibilita' => 'required|in:pubblico,individuo,gruppo', 'visibilita_target_id' => 'nullable|integer', 'visibilita_target_type' => 'nullable|string', @@ -257,7 +268,47 @@ class DocumentoController extends Controller '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; @@ -321,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; @@ -366,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', @@ -416,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); @@ -424,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); + } } } @@ -604,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; @@ -638,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) @@ -659,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(); diff --git a/app/Http/Controllers/EmailController.php b/app/Http/Controllers/EmailController.php index 73a804ec..d65463c8 100644 --- a/app/Http/Controllers/EmailController.php +++ b/app/Http/Controllers/EmailController.php @@ -162,14 +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'], $firmaId, $settings); - $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(); @@ -178,7 +182,7 @@ class EmailController extends Controller } try { - $bodyWithSig = $this->appendSignature($validated['body'], $firmaId, $settings); + $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()]); @@ -195,10 +199,13 @@ 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->appendSignature($validated['body'], $request->firma_id, $settings); + $body = $this->appendLinksToBody($validated['body'], $linkDocs); + $body = $this->appendSignature($body, $request->firma_id, $settings); $ok = 0; $errors = []; @@ -583,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) { @@ -597,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) { @@ -623,13 +634,29 @@ 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) diff --git a/app/Http/Controllers/GruppoController.php b/app/Http/Controllers/GruppoController.php index 38f69ee1..3fbffd47 100644 --- a/app/Http/Controllers/GruppoController.php +++ b/app/Http/Controllers/GruppoController.php @@ -37,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'], @@ -155,6 +156,7 @@ 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_ids' => 'nullable|array', @@ -244,6 +246,7 @@ 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_ids' => 'nullable|array', @@ -478,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, @@ -512,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); diff --git a/app/Http/Controllers/MailingController.php b/app/Http/Controllers/MailingController.php index d74bd53d..f5f1c396 100644 --- a/app/Http/Controllers/MailingController.php +++ b/app/Http/Controllers/MailingController.php @@ -120,7 +120,9 @@ 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'])) { @@ -128,7 +130,7 @@ class MailingController extends Controller $firmaId = $lista?->firma?->id; } - $corpoConFirma = $data['corpo']; + $corpoConFirma = $this->appendMailingLinksToBody($data['corpo'], $linkDocs); if ($firmaId) { $firma = Firma::find($firmaId); if ($firma && $firma->contenuto_html) { @@ -284,14 +286,16 @@ 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']; $firmaId = $data['firma_id'] ?? null; if (!$firmaId && $listeModels->count() === 1) { $firmaId = $listeModels->first()->firma_id; } - $corpoConFirma = $data['corpo']; + $corpoConFirma = $this->appendMailingLinksToBody($data['corpo'], $linkDocs); if ($firmaId) { $firma = Firma::find($firmaId); if ($firma && $firma->contenuto_html) { @@ -419,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; } } diff --git a/app/Models/Documento.php b/app/Models/Documento.php index eb4b2937..dddbe09a 100644 --- a/app/Models/Documento.php +++ b/app/Models/Documento.php @@ -18,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); diff --git a/app/Models/Gruppo.php b/app/Models/Gruppo.php index 29b2c93d..a4f742d1 100644 --- a/app/Models/Gruppo.php +++ b/app/Models/Gruppo.php @@ -15,13 +15,14 @@ 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']; diff --git a/database/migrations/2024_01_01_000008_create_documenti_table.php b/database/migrations/2024_01_01_000008_create_documenti_table.php index 247ca532..2274d27f 100644 --- a/database/migrations/2024_01_01_000008_create_documenti_table.php +++ b/database/migrations/2024_01_01_000008_create_documenti_table.php @@ -13,10 +13,10 @@ return new class extends Migration $table->id(); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->onDelete('set null'); $table->string('nome_file'); - $table->string('file_path'); + $table->string('file_path')->nullable(); $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->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(); diff --git a/database/migrations/2026_05_11_000005_add_email_attachment_to_documenti.php b/database/migrations/2026_05_11_000005_add_email_attachment_to_documenti.php index 0d1f5ae8..6a5a09f9 100644 --- a/database/migrations/2026_05_11_000005_add_email_attachment_to_documenti.php +++ b/database/migrations/2026_05_11_000005_add_email_attachment_to_documenti.php @@ -1,22 +1,19 @@ 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. } }; \ No newline at end of file diff --git a/database/migrations/2026_05_12_000002_change_ruolo_to_multi.php b/database/migrations/2026_05_12_000002_change_ruolo_to_multi.php index 6c6d1279..f1f250e4 100644 --- a/database/migrations/2026_05_12_000002_change_ruolo_to_multi.php +++ b/database/migrations/2026_05_12_000002_change_ruolo_to_multi.php @@ -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) { diff --git a/database/migrations/2026_05_13_072203_add_programma_locandina_to_tipologia_enum.php b/database/migrations/2026_05_13_072203_add_programma_locandina_to_tipologia_enum.php index ad724374..6a5a09f9 100644 --- a/database/migrations/2026_05_13_072203_add_programma_locandina_to_tipologia_enum.php +++ b/database/migrations/2026_05_13_072203_add_programma_locandina_to_tipologia_enum.php @@ -1,17 +1,19 @@ 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'); + }); + } + } +}; diff --git a/database/migrations/2026_06_23_075107_add_url_to_documenti_table.php b/database/migrations/2026_06_23_075107_add_url_to_documenti_table.php new file mode 100644 index 00000000..02e5c82d --- /dev/null +++ b/database/migrations/2026_06_23_075107_add_url_to_documenti_table.php @@ -0,0 +1,28 @@ +text('url')->nullable()->after('note'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('documenti', function (Blueprint $table) { + $table->dropColumn('url'); + }); + } +}; diff --git a/database/migrations/2026_06_23_085637_make_file_path_nullable_in_documenti.php b/database/migrations/2026_06_23_085637_make_file_path_nullable_in_documenti.php new file mode 100644 index 00000000..8b6c5928 --- /dev/null +++ b/database/migrations/2026_06_23_085637_make_file_path_nullable_in_documenti.php @@ -0,0 +1,24 @@ +&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 diff --git a/resources/views/documenti/edit.blade.php b/resources/views/documenti/edit.blade.php index 0e87f886..681942c7 100644 --- a/resources/views/documenti/edit.blade.php +++ b/resources/views/documenti/edit.blade.php @@ -16,6 +16,13 @@ + @if($documento->isLink()) +
| URL: | +{{ $documento->url }} | +||
| File: | {{ $documento->file_path }} | @@ -84,6 +97,11 @@Dimensione: | {{ $documento->dimensione ? number_format($documento->dimensione / 1024, 1) . ' KB' : '-' }} |
| Tipo: | +@if($documento->isLink()) Link esterno @else Upload @endif | +||
| Creato: | {{ $documento->created_at->format('d/m/Y H:i') }} | @@ -95,7 +113,12 @@