Roberto Santini 1 nedēļu atpakaļ
vecāks
revīzija
61c5e2269d

+ 49
- 1
app/Console/Commands/ImportaRedmine.php Parādīt failu

@@ -27,7 +27,8 @@ class ImportaRedmine extends Command
27 27
                             {--user-fallback= : ID utente locale se non c\'è match}
28 28
                             {--tariffa= : €/ora per calcolare il costo dai tempi}
29 29
                             {--contratto= : ID del contratto servizio da assegnare alle segnalazioni}
30
-                            {--limit= : Importa al massimo N issue (per progetto)}';
30
+                            {--limit= : Importa al massimo N issue (per progetto)}
31
+                            {--sync-cc : Aggiorna solo CC/CCN dalle ticket Helpdesk, senza reimportare le issue}';
31 32
 
32 33
     protected $description = 'Importa issue, tempi, costi e allegati da Redmine 6.x. Senza progetto importa tutti i progetti e crea le aziende mancanti.';
33 34
 
@@ -48,6 +49,10 @@ class ImportaRedmine extends Command
48 49
             return $this->listProjects($client);
49 50
         }
50 51
 
52
+        if ($this->option('sync-cc')) {
53
+            return $this->syncCc($client);
54
+        }
55
+
51 56
         $projectArg = $this->argument('project');
52 57
         $aziendaArg = $this->argument('azienda');
53 58
 
@@ -430,6 +435,49 @@ class ImportaRedmine extends Command
430 435
         }
431 436
     }
432 437
 
438
+    protected function syncCc(RedmineClient $client): int
439
+    {
440
+        $limit = $this->option('limit');
441
+        $this->info('Sincronizzo CC/CCN dalle ticket Helpdesk Redmine ('.$client->baseUrl().').');
442
+        if ($this->option('dry-run')) {
443
+            $this->warn('Modalità dry-run: nessuna scrittura sul database.');
444
+        }
445
+
446
+        try {
447
+            $stats = RedmineImportService::syncHelpdeskCc(
448
+                $client,
449
+                (bool) $this->option('dry-run'),
450
+                $limit !== null && $limit !== '' ? (int) $limit : null,
451
+                function (int $current, int $total) {
452
+                    if ($current === 1) {
453
+                        $this->output->progressStart($total);
454
+                    }
455
+                    $this->output->progressAdvance();
456
+                    if ($current === $total) {
457
+                        $this->output->progressFinish();
458
+                    }
459
+                },
460
+            );
461
+        } catch (Throwable $e) {
462
+            $this->error($e->getMessage());
463
+
464
+            return self::FAILURE;
465
+        }
466
+
467
+        $this->newLine();
468
+        $this->table(
469
+            ['Voce', 'Valore'],
470
+            [
471
+                ['Segnalazioni Redmine', $stats['tickets']],
472
+                ['Segnalazioni aggiornate', $stats['updated']],
473
+                ['Senza ticket Helpdesk / già allineate', $stats['skipped']],
474
+                ['Senza CC da importare', $stats['empty']],
475
+            ],
476
+        );
477
+
478
+        return self::SUCCESS;
479
+    }
480
+
433 481
     /**
434 482
      * @param  array<string, mixed>  $stats
435 483
      */

+ 19
- 0
app/Http/Controllers/SegnalazioneController.php Parādīt failu

@@ -165,6 +165,8 @@ class SegnalazioneController extends Controller implements HasMiddleware
165 165
       'slots.*.ora_fine' => 'nullable|date_format:H:i',
166 166
       'allegato' => 'nullable|array',
167 167
       'allegato.*' => 'file|max:10240', // max 10MB per file
168
+      'cc' => 'nullable|string',
169
+      'bcc' => 'nullable|string',
168 170
     ]);
169 171
 
170 172
     if ($request->filled('ordine_lavoro_id') && $request->filled('contratto_servizio_id')) {
@@ -241,6 +243,7 @@ class SegnalazioneController extends Controller implements HasMiddleware
241 243
     $segnalazione->tempo_stimato = $request->filled('tempo_stimato')
242 244
       ? round((float) $request->input('tempo_stimato'), 2)
243 245
       : null;
246
+    $this->applyCcBccFromRequest($segnalazione, $request);
244 247
     $segnalazione->save();
245 248
 
246 249
     // Sync slot prenotazione (solo in stato pianificata; altrimenti svuota)
@@ -319,6 +322,8 @@ class SegnalazioneController extends Controller implements HasMiddleware
319 322
       'descrizione' => 'required|string',
320 323
       'allegato' => 'nullable|array',
321 324
       'allegato.*' => 'file|max:10240', // max 10MB per file
325
+      'cc' => 'nullable|string',
326
+      'bcc' => 'nullable|string',
322 327
     ]);
323 328
 
324 329
     $validator->after(function ($validator) use ($request) {
@@ -345,6 +350,7 @@ class SegnalazioneController extends Controller implements HasMiddleware
345 350
         $segnalazione->origine = 'manuale';
346 351
       }
347 352
       $segnalazione->notificaApertaDa = $request->boolean('notifica_aperta_da');
353
+      $this->applyCcBccFromRequest($segnalazione, $request);
348 354
       $categoriaSicurezzaValue = $request->input('categoria_sicurezza');
349 355
       if ($categoriaSicurezzaValue !== null && $categoriaSicurezzaValue !== '') {
350 356
         $tipoConfig = ConfiguraSegnalazione::where('gruppo', 'Tipo')
@@ -813,6 +819,19 @@ class SegnalazioneController extends Controller implements HasMiddleware
813 819
         return $query->get();
814 820
     }
815 821
 
822
+    protected function applyCcBccFromRequest(Segnalazione $segnalazione, Request $request): void
823
+    {
824
+        $exclude = [];
825
+        $userId = $request->filled('user_id') ? (int) $request->user_id : (int) $segnalazione->user_id;
826
+        if ($userId > 0) {
827
+            $email = User::query()->where('id', $userId)->value('email');
828
+            if ($email) {
829
+                $exclude[] = (string) $email;
830
+            }
831
+        }
832
+        $segnalazione->applyDestinatariFromRequest($request->input('cc'), $request->input('bcc'), $exclude);
833
+    }
834
+
816 835
     public function admin_delete(Request $request, $id)
817 836
     {
818 837
       $segnalazione = Segnalazione::find($id);

+ 185
- 1
app/Models/Segnalazione.php Parādīt failu

@@ -17,6 +17,22 @@ class Segnalazione extends \App\Models\AbstractModels\AbstractSegnalazione imple
17 17
   /** Flag runtime: non notificare il cliente (Aperta da) su questo salvataggio. */
18 18
   public bool $skipNotificaCliente = false;
19 19
 
20
+  public function getCasts()
21
+  {
22
+    return array_merge(parent::getCasts(), [
23
+      'cc' => 'array',
24
+      'bcc' => 'array',
25
+    ]);
26
+  }
27
+
28
+  public function getFillable()
29
+  {
30
+    return array_merge(parent::getFillable(), [
31
+      'cc',
32
+      'bcc',
33
+    ]);
34
+  }
35
+
20 36
   // public function getCasts()
21 37
   // {
22 38
   //   return array_merge(parent::getCasts(), [
@@ -405,7 +421,175 @@ class Segnalazione extends \App\Models\AbstractModels\AbstractSegnalazione imple
405 421
     }
406 422
 
407 423
     /**
408
-     * Notifica pubblica all'utente "Aperta da", come per un'annotazione standard.
424
+     * Normalizza una lista di indirizzi (JSON Tagify, CSV, array).
425
+     *
426
+     * @return list<string>
427
+     */
428
+    public static function parseEmailList(mixed $value): array
429
+    {
430
+        if ($value === null || $value === '') {
431
+            return [];
432
+        }
433
+        if (is_array($value)) {
434
+            $items = $value;
435
+        } else {
436
+            $raw = trim((string) $value);
437
+            $decoded = json_decode($raw, true);
438
+            $items = is_array($decoded) ? $decoded : (preg_split('/[;,]+/', $raw) ?: []);
439
+        }
440
+
441
+        $emails = [];
442
+        foreach ($items as $item) {
443
+            if (is_array($item)) {
444
+                $item = $item['value'] ?? $item['email'] ?? $item['address'] ?? $item['mail'] ?? null;
445
+            }
446
+            $email = strtolower(trim((string) $item));
447
+            if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
448
+                $emails[] = $email;
449
+            }
450
+        }
451
+
452
+        return array_values(array_unique($emails));
453
+    }
454
+
455
+    /**
456
+     * @return list<string>
457
+     */
458
+    public static function internalMailboxEmails(): array
459
+    {
460
+        $emails = [
461
+            (string) config('mail.from.address'),
462
+            (string) config('mail.reply_to.address'),
463
+        ];
464
+        $from = strtolower(trim((string) config('mail.from.address')));
465
+        if ($from !== '' && str_contains($from, '@')) {
466
+            $domain = substr($from, strrpos($from, '@'));
467
+            foreach (['assistenza', 'assistenzatest', 'noreply', 'no-reply'] as $local) {
468
+                $emails[] = $local.$domain;
469
+            }
470
+        }
471
+        try {
472
+            $emails = array_merge($emails, CasellaImap::query()->pluck('username')->all());
473
+        } catch (\Throwable) {
474
+            // tabella assente in alcuni ambienti
475
+        }
476
+
477
+        return array_values(array_unique(array_filter(array_map(
478
+            static fn ($e) => strtolower(trim((string) $e)),
479
+            $emails
480
+        ), static fn ($e) => $e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL))));
481
+    }
482
+
483
+    /**
484
+     * @param  list<string>  $exclude
485
+     * @return list<string>
486
+     */
487
+    public function emailDestinatari(string $campo, array $exclude = []): array
488
+    {
489
+        $emails = self::parseEmailList($this->{$campo} ?? []);
490
+        $exclude = array_map('strtolower', array_filter($exclude));
491
+        $internal = self::internalMailboxEmails();
492
+
493
+        return array_values(array_filter(
494
+            $emails,
495
+            static fn (string $email) => ! in_array($email, $exclude, true) && ! in_array($email, $internal, true)
496
+        ));
497
+    }
498
+
499
+    /**
500
+     * @param  list<string>  $exclude
501
+     */
502
+    public function applyEmailDestinatari(\Illuminate\Notifications\Messages\MailMessage $mail, array $exclude = []): \Illuminate\Notifications\Messages\MailMessage
503
+    {
504
+        $cc = $this->emailDestinatari('cc', $exclude);
505
+        $bcc = $this->emailDestinatari('bcc', array_merge($exclude, $cc));
506
+        if ($cc !== []) {
507
+            $mail->cc($cc);
508
+        }
509
+        if ($bcc !== []) {
510
+            $mail->bcc($bcc);
511
+        }
512
+
513
+        return $mail;
514
+    }
515
+
516
+    /**
517
+     * Unisce indirizzi in CC (es. da mail in ingresso), senza toccare il mittente né le caselle interne.
518
+     *
519
+     * @param  list<string>  $emails
520
+     */
521
+    public function mergeCcEmails(array $emails, array $exclude = []): void
522
+    {
523
+        $exclude = array_map('strtolower', array_merge($exclude, self::internalMailboxEmails()));
524
+        $current = self::parseEmailList($this->cc);
525
+        foreach (self::parseEmailList($emails) as $email) {
526
+            if (! in_array($email, $exclude, true) && ! in_array($email, $current, true)) {
527
+                $current[] = $email;
528
+            }
529
+        }
530
+        $this->cc = $current !== [] ? $current : null;
531
+    }
532
+
533
+    /**
534
+     * Imposta CC (e BCC se presente) da un ticket Helpdesk Redmine.
535
+     *
536
+     * @param  array<string, mixed>|null  $ticket
537
+     */
538
+    public function applyHelpdeskAddresses(?array $ticket): void
539
+    {
540
+        if (! is_array($ticket)) {
541
+            return;
542
+        }
543
+
544
+        $from = strtolower(trim((string) ($ticket['from_address'] ?? '')));
545
+        $this->cc = null;
546
+        $this->mergeCcEmails(
547
+            array_merge(
548
+                self::parseEmailList($ticket['cc_address'] ?? ''),
549
+                self::parseEmailList($ticket['to_address'] ?? ''),
550
+            ),
551
+            array_filter([$from])
552
+        );
553
+
554
+        $bcc = self::parseEmailList($ticket['bcc_address'] ?? '');
555
+        if ($bcc === []) {
556
+            return;
557
+        }
558
+        $exclude = array_merge(
559
+            array_filter([$from]),
560
+            self::parseEmailList($this->cc),
561
+            self::internalMailboxEmails()
562
+        );
563
+        $bcc = array_values(array_filter(
564
+            $bcc,
565
+            static fn (string $email) => ! in_array($email, $exclude, true)
566
+        ));
567
+        $this->bcc = $bcc !== [] ? $bcc : null;
568
+    }
569
+
570
+    /**
571
+     * Imposta CC/CCN dal form (Tagify JSON, CSV o array).
572
+     */
573
+    public function applyDestinatariFromRequest(mixed $cc, mixed $bcc, array $exclude = []): void
574
+    {
575
+        $exclude = array_map('strtolower', array_filter($exclude));
576
+        $internal = self::internalMailboxEmails();
577
+        $ccList = array_values(array_filter(
578
+            self::parseEmailList($cc),
579
+            static fn (string $email) => ! in_array($email, $exclude, true) && ! in_array($email, $internal, true)
580
+        ));
581
+        $bccList = array_values(array_filter(
582
+            self::parseEmailList($bcc),
583
+            static fn (string $email) => ! in_array($email, $exclude, true)
584
+                && ! in_array($email, $ccList, true)
585
+                && ! in_array($email, $internal, true)
586
+        ));
587
+        $this->cc = $ccList !== [] ? $ccList : null;
588
+        $this->bcc = $bccList !== [] ? $bccList : null;
589
+    }
590
+
591
+    /**
592
+     * Notifica pubblica all'utente "Aperta da" (To) e agli indirizzi in CC/BCC.
409 593
      */
410 594
     public function notifyApertaDaAnnotazionePubblica(Annotazione $annotazione): void
411 595
     {

+ 5
- 1
app/Notifications/AnnotazionePubblicaApertaDa.php Parādīt failu

@@ -45,7 +45,7 @@ class AnnotazionePubblicaApertaDa extends Notification implements ShouldQueue
45 45
             ? $this->annotazione->user->full_name
46 46
             : trim((string) ($this->annotazione->riferimento ?: 'Operatore'));
47 47
 
48
-        return (new MailMessage)
48
+        $mail = (new MailMessage)
49 49
             ->subject($this->segnalazione->mailSubject(true))
50 50
             ->markdown('mail.annotazione-pubblica-aperta-da', [
51 51
                 'segnalazione' => $this->segnalazione,
@@ -56,6 +56,10 @@ class AnnotazionePubblicaApertaDa extends Notification implements ShouldQueue
56 56
                 'prioritaLabel' => $prioritaLabel,
57 57
                 'autoreLabel' => $autoreLabel,
58 58
             ]);
59
+
60
+        return $this->segnalazione->applyEmailDestinatari($mail, [
61
+            strtolower((string) ($notifiable->email ?? '')),
62
+        ]);
59 63
     }
60 64
 
61 65
     /**

+ 6
- 0
app/Notifications/NuovaSegnalazioneUser.php Parādīt failu

@@ -77,6 +77,12 @@ class NuovaSegnalazioneUser extends Notification implements ShouldQueue
77 77
             report($e);
78 78
         }
79 79
 
80
+        if ($this->isApertaDa) {
81
+            return $this->segnalazione->applyEmailDestinatari($mail, [
82
+                strtolower((string) ($notifiable->email ?? '')),
83
+            ]);
84
+        }
85
+
80 86
         return $mail;
81 87
     }
82 88
 

+ 18
- 1
app/Notifications/RispostaAutoSegnalazioneEmail.php Parādīt failu

@@ -32,7 +32,7 @@ class RispostaAutoSegnalazioneEmail extends Notification implements ShouldQueue
32 32
         $body = Config::testoRispostaAutoEmail($this->segnalazione);
33 33
         $paragraphs = preg_split("/\R{2,}/", trim($body)) ?: [];
34 34
 
35
-        return (new MailMessage)
35
+        $mail = (new MailMessage)
36 36
             ->subject($this->segnalazione->mailSubject(true))
37 37
             ->markdown('mail.risposta-auto-segnalazione-email', [
38 38
                 'segnalazione' => $this->segnalazione,
@@ -41,6 +41,23 @@ class RispostaAutoSegnalazioneEmail extends Notification implements ShouldQueue
41 41
                     $paragraphs
42 42
                 ), static fn ($p) => $p !== '')),
43 43
             ]);
44
+
45
+        $to = '';
46
+        if (isset($notifiable->routes['mail'])) {
47
+            $route = $notifiable->routes['mail'];
48
+            if (is_array($route)) {
49
+                $firstKey = array_key_first($route);
50
+                $to = is_string($firstKey) && str_contains($firstKey, '@')
51
+                    ? $firstKey
52
+                    : (string) reset($route);
53
+            } else {
54
+                $to = (string) $route;
55
+            }
56
+        } else {
57
+            $to = (string) ($notifiable->email ?? '');
58
+        }
59
+
60
+        return $this->segnalazione->applyEmailDestinatari($mail, [strtolower($to)]);
44 61
     }
45 62
 
46 63
     /**

+ 62
- 0
app/Services/Redmine/RedmineClient.php Parādīt failu

@@ -241,6 +241,68 @@ class RedmineClient
241 241
         return is_array($data['helpdesk_ticket'] ?? null) ? $data['helpdesk_ticket'] : null;
242 242
     }
243 243
 
244
+    /**
245
+     * Elenco ticket Helpdesk (plugin RedmineUP). L'id coincide con l'issue.
246
+     *
247
+     * @return list<array<string, mixed>>
248
+     */
249
+    public function helpdeskTickets(?int $limit = null): array
250
+    {
251
+        $all = [];
252
+        $offset = 0;
253
+        $pageSize = 25;
254
+        $total = null;
255
+
256
+        try {
257
+            do {
258
+                try {
259
+                    $data = $this->get('helpdesk_tickets.json', [
260
+                        'offset' => $offset,
261
+                        'limit' => $pageSize,
262
+                    ]);
263
+                } catch (RuntimeException $e) {
264
+                    if ($e->getCode() !== 500) {
265
+                        throw $e;
266
+                    }
267
+                    if ($pageSize > 1) {
268
+                        $pageSize = 1;
269
+                        continue;
270
+                    }
271
+                    $offset++;
272
+                    if ($total !== null && $offset >= $total) {
273
+                        break;
274
+                    }
275
+                    continue;
276
+                }
277
+                $chunk = $data['helpdesk_tickets'] ?? [];
278
+                if (! is_array($chunk) || $chunk === []) {
279
+                    break;
280
+                }
281
+                foreach ($chunk as $item) {
282
+                    if (is_array($item)) {
283
+                        $all[] = $item;
284
+                    }
285
+                    if ($limit !== null && count($all) >= $limit) {
286
+                        return $all;
287
+                    }
288
+                }
289
+                $total = (int) ($data['total_count'] ?? ($offset + count($chunk)));
290
+                $offset += count($chunk);
291
+                if ($pageSize === 1 && count($chunk) === 1) {
292
+                    $pageSize = 25;
293
+                }
294
+            } while ($total === null || $offset < $total);
295
+        } catch (RuntimeException $e) {
296
+            if (in_array($e->getCode(), [404], true) && $all === []) {
297
+                return [];
298
+            }
299
+
300
+            throw $e;
301
+        }
302
+
303
+        return $all;
304
+    }
305
+
244 306
     /**
245 307
      * @return array<string, mixed>|null
246 308
      */

+ 96
- 0
app/Services/Redmine/RedmineImportService.php Parādīt failu

@@ -412,6 +412,13 @@ class RedmineImportService
412 412
             'contact_id' => $this->lastContactMeta['id'] ?? null,
413 413
             'helpdesk_from' => $this->lastContactMeta['email'] ?? null,
414 414
         ]);
415
+        $helpdeskTicket = $issueId > 0 ? $this->cachedHelpdeskTicket($issueId) : null;
416
+        if (is_array($helpdeskTicket)) {
417
+            $segnalazione->applyHelpdeskAddresses($helpdeskTicket);
418
+            $meta['redmine']['helpdesk_from'] = $helpdeskTicket['from_address'] ?? $meta['redmine']['helpdesk_from'];
419
+            $meta['redmine']['helpdesk_to'] = $helpdeskTicket['to_address'] ?? null;
420
+            $meta['redmine']['helpdesk_cc'] = $helpdeskTicket['cc_address'] ?? null;
421
+        }
415 422
         $segnalazione->meta = $meta;
416 423
         Segnalazione::withoutAuditing(function () use ($segnalazione) {
417 424
             $segnalazione->save();
@@ -1044,6 +1051,95 @@ class RedmineImportService
1044 1051
         return is_array($cached) ? $cached : null;
1045 1052
     }
1046 1053
 
1054
+    /**
1055
+     * Allinea CC/CCN delle segnalazioni già importate dai ticket Helpdesk, senza reimportare le issue.
1056
+     *
1057
+     * @return array{tickets:int, updated:int, skipped:int, empty:int}
1058
+     */
1059
+    public static function syncHelpdeskCc(RedmineClient $client, bool $dryRun = false, ?int $limit = null, ?callable $onProgress = null): array
1060
+    {
1061
+        $query = Segnalazione::query()->where('origine', 'redmine')->orderBy('id');
1062
+        if ($limit !== null) {
1063
+            $query->limit(max(0, $limit));
1064
+        }
1065
+        $segnalazioni = $query->get();
1066
+        $stats = [
1067
+            'tickets' => $segnalazioni->count(),
1068
+            'updated' => 0,
1069
+            'skipped' => 0,
1070
+            'empty' => 0,
1071
+        ];
1072
+        $total = $segnalazioni->count();
1073
+        $current = 0;
1074
+
1075
+        foreach ($segnalazioni as $segnalazione) {
1076
+            $current++;
1077
+            $issueId = (int) data_get($segnalazione->meta, 'redmine.issue_id', $segnalazione->id);
1078
+            if ($onProgress) {
1079
+                $onProgress($current, $total, $issueId);
1080
+            }
1081
+            if ($issueId <= 0) {
1082
+                $stats['skipped']++;
1083
+                continue;
1084
+            }
1085
+
1086
+            $ticket = $client->helpdeskTicket($issueId);
1087
+            if (! is_array($ticket)) {
1088
+                $stats['skipped']++;
1089
+                continue;
1090
+            }
1091
+
1092
+            $beforeCc = self::normalizeEmailList($segnalazione->cc);
1093
+            $segnalazione->applyHelpdeskAddresses($ticket);
1094
+            $afterCc = self::normalizeEmailList($segnalazione->cc);
1095
+
1096
+            $meta = is_array($segnalazione->meta) ? $segnalazione->meta : [];
1097
+            $meta['redmine'] = array_merge($meta['redmine'] ?? [], [
1098
+                'helpdesk_from' => $ticket['from_address'] ?? data_get($meta, 'redmine.helpdesk_from'),
1099
+                'helpdesk_to' => $ticket['to_address'] ?? null,
1100
+                'helpdesk_cc' => $ticket['cc_address'] ?? null,
1101
+            ]);
1102
+            $segnalazione->meta = $meta;
1103
+
1104
+            if ($beforeCc === $afterCc && $afterCc === []) {
1105
+                $stats['empty']++;
1106
+                if (! $dryRun && $segnalazione->isDirty('meta')) {
1107
+                    Segnalazione::withoutAuditing(function () use ($segnalazione) {
1108
+                        $segnalazione->save();
1109
+                    });
1110
+                }
1111
+                continue;
1112
+            }
1113
+
1114
+            if ($beforeCc === $afterCc && ! $segnalazione->isDirty('bcc')) {
1115
+                $stats['skipped']++;
1116
+                continue;
1117
+            }
1118
+
1119
+            $stats['updated']++;
1120
+            if ($dryRun) {
1121
+                continue;
1122
+            }
1123
+
1124
+            Segnalazione::withoutAuditing(function () use ($segnalazione) {
1125
+                $segnalazione->save();
1126
+            });
1127
+        }
1128
+
1129
+        return $stats;
1130
+    }
1131
+
1132
+    /**
1133
+     * @return list<string>
1134
+     */
1135
+    protected static function normalizeEmailList(mixed $value): array
1136
+    {
1137
+        $emails = Segnalazione::parseEmailList($value);
1138
+        sort($emails);
1139
+
1140
+        return $emails;
1141
+    }
1142
+
1047 1143
     protected function cachedContact(int $contactId): ?array
1048 1144
     {
1049 1145
         if (! array_key_exists($contactId, $this->contactCache)) {

+ 62
- 0
app/Services/SegnalazioneEmailInboxService.php Parādīt failu

@@ -251,6 +251,8 @@ class SegnalazioneEmailInboxService
251 251
             $created = true;
252 252
         }
253 253
 
254
+        $this->syncCcFromMessage($segnalazione, $message, $fromAddress);
255
+
254 256
         $annotazione = $this->createAnnotazioneFromMessage($segnalazione, $message, $fromAddress, ! $created);
255 257
         $this->storeAttachments($segnalazione, $annotazione, $message);
256 258
 
@@ -841,6 +843,66 @@ class SegnalazioneEmailInboxService
841 843
         return $personal !== '' ? $personal : ($first->mail ? (string) $first->mail : null);
842 844
     }
843 845
 
846
+    protected function syncCcFromMessage(Segnalazione $segnalazione, Message $message, ?string $fromAddress): void
847
+    {
848
+        $emails = $this->extractLoopCcEmails($message, $fromAddress);
849
+        if ($emails === []) {
850
+            return;
851
+        }
852
+
853
+        $segnalazione->mergeCcEmails($emails, array_filter([(string) $fromAddress]));
854
+        if ($segnalazione->isDirty('cc')) {
855
+            $segnalazione->save();
856
+        }
857
+    }
858
+
859
+    /**
860
+     * @return list<string>
861
+     */
862
+    protected function extractLoopCcEmails(Message $message, ?string $fromAddress): array
863
+    {
864
+        $emails = array_merge(
865
+            $this->extractAddressEmails($message->getCc()),
866
+            $this->extractAddressEmails($message->getTo()),
867
+        );
868
+        $exclude = array_merge(
869
+            Segnalazione::internalMailboxEmails(),
870
+            array_filter([strtolower((string) $fromAddress)])
871
+        );
872
+
873
+        return array_values(array_filter(
874
+            array_unique($emails),
875
+            static fn (string $email) => ! in_array($email, $exclude, true)
876
+        ));
877
+    }
878
+
879
+    /**
880
+     * @return list<string>
881
+     */
882
+    protected function extractAddressEmails(mixed $addresses): array
883
+    {
884
+        if (! $addresses) {
885
+            return [];
886
+        }
887
+
888
+        $emails = [];
889
+        try {
890
+            foreach ($addresses as $addr) {
891
+                $mail = is_object($addr) ? ($addr->mail ?? null) : $addr;
892
+                $mail = strtolower(trim((string) $mail));
893
+                if ($mail !== '' && filter_var($mail, FILTER_VALIDATE_EMAIL)) {
894
+                    $emails[] = $mail;
895
+                }
896
+            }
897
+        } catch (\Throwable) {
898
+            if (is_object($addresses) && method_exists($addresses, 'toString')) {
899
+                $emails = Segnalazione::parseEmailList($addresses->toString());
900
+            }
901
+        }
902
+
903
+        return array_values(array_unique($emails));
904
+    }
905
+
844 906
     protected function alreadyImported(Segnalazione $segnalazione, string $messageId): bool
845 907
     {
846 908
         $meta = is_array($segnalazione->meta) ? $segnalazione->meta : [];

+ 23
- 0
database/migrations/2026_09_08_090000_add_cc_bcc_to_segnalazione.php Parādīt failu

@@ -0,0 +1,23 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    public function up(): void
10
+    {
11
+        Schema::table('segnalazione', function (Blueprint $table) {
12
+            $table->json('cc')->nullable()->after('origine');
13
+            $table->json('bcc')->nullable()->after('cc');
14
+        });
15
+    }
16
+
17
+    public function down(): void
18
+    {
19
+        Schema::table('segnalazione', function (Blueprint $table) {
20
+            $table->dropColumn(['cc', 'bcc']);
21
+        });
22
+    }
23
+};

+ 16
- 0
resources/views/segnalazione/admin/_cc_bcc_fields.blade.php Parādīt failu

@@ -0,0 +1,16 @@
1
+@php
2
+  $ccValue = $ccValue ?? '';
3
+  $bccValue = $bccValue ?? '';
4
+@endphp
5
+<div class="row g-3" id="wrapSegnalazioneCcBcc">
6
+  <div class="col-md-6">
7
+    <label for="segnalazione_cc" class="form-label">CC</label>
8
+    <input type="text" class="form-control" id="segnalazione_cc" name="cc" value="{{ $ccValue }}" placeholder="indirizzo@esempio.it">
9
+    <div class="form-text">In copia sulle risposte pubbliche al cliente, come in Redmine.</div>
10
+  </div>
11
+  <div class="col-md-6">
12
+    <label for="segnalazione_bcc" class="form-label">CCN</label>
13
+    <input type="text" class="form-control" id="segnalazione_bcc" name="bcc" value="{{ $bccValue }}" placeholder="copia nascosta">
14
+    <div class="form-text">Copia nascosta, non visibile agli altri destinatari.</div>
15
+  </div>
16
+</div>

+ 25
- 2
resources/views/segnalazione/admin/nuova.blade.php Parādīt failu

@@ -17,7 +17,8 @@ $selectedContrattoId = old('contratto_servizio_id');
17 17
 @vite([
18 18
 'resources/assets/vendor/libs/datatables-bs5/datatables-bootstrap5.js',
19 19
 'resources/assets/vendor/libs/quill/katex.js',
20
-'resources/assets/vendor/libs/quill/quill.js'
20
+'resources/assets/vendor/libs/quill/quill.js',
21
+'resources/assets/vendor/libs/tagify/tagify.js'
21 22
 ])
22 23
 @endsection
23 24
 
@@ -26,7 +27,8 @@ $selectedContrattoId = old('contratto_servizio_id');
26 27
 'resources/assets/vendor/libs/datatables-rowgroup-bs5/rowgroup.bootstrap5.scss',
27 28
 'resources/assets/vendor/libs/quill/typography.scss',
28 29
 'resources/assets/vendor/libs/quill/katex.scss',
29
-'resources/assets/vendor/libs/quill/editor.scss'
30
+'resources/assets/vendor/libs/quill/editor.scss',
31
+'resources/assets/vendor/libs/tagify/tagify.scss'
30 32
 ])
31 33
 @endsection
32 34
 
@@ -112,6 +114,13 @@ $selectedContrattoId = old('contratto_servizio_id');
112 114
           </div>
113 115
         </div>
114 116
 
117
+        <div class="mb-4">
118
+          @include('segnalazione.admin._cc_bcc_fields', [
119
+            'ccValue' => old('cc', ''),
120
+            'bccValue' => old('bcc', ''),
121
+          ])
122
+        </div>
123
+
115 124
         <div class="row g-3 mb-4 align-items-start" id="rowOrdineContratto">
116 125
           <div class="col-12 col-lg-6" id="wrapOrdineLavoro">
117 126
             <div class="row g-2 align-items-stretch">
@@ -286,6 +295,20 @@ $selectedContrattoId = old('contratto_servizio_id');
286 295
 @section('page-script')
287 296
 <script type="module">
288 297
   $(document).ready(function() {
298
+    if (typeof window.Tagify === 'function') {
299
+      ['#segnalazione_cc', '#segnalazione_bcc'].forEach(function (sel) {
300
+        const el = document.querySelector(sel);
301
+        if (!el || el.dataset.tagifyReady) return;
302
+        el.dataset.tagifyReady = '1';
303
+        new window.Tagify(el, {
304
+          delimiters: ',|;',
305
+          duplicates: false,
306
+          pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
307
+          dropdown: { enabled: 0 },
308
+          originalInputValueFormat: (values) => values.map((v) => v.value).join(',')
309
+        });
310
+      });
311
+    }
289 312
     function initQuillEditor(editorId, toolbarId, hiddenInputId) {
290 313
       const editor = new Quill('#' + editorId, {
291 314
         bounds: '#' + editorId,

+ 48
- 2
resources/views/segnalazione/admin/show.blade.php Parādīt failu

@@ -12,7 +12,8 @@ use App\Models\Config;
12 12
 @vite([
13 13
 'resources/assets/vendor/libs/datatables-bs5/datatables-bootstrap5.js',
14 14
 'resources/assets/vendor/libs/quill/katex.js',
15
-'resources/assets/vendor/libs/quill/quill.js'
15
+'resources/assets/vendor/libs/quill/quill.js',
16
+'resources/assets/vendor/libs/tagify/tagify.js'
16 17
 ])
17 18
 @endsection
18 19
 
@@ -21,7 +22,8 @@ use App\Models\Config;
21 22
 'resources/assets/vendor/libs/datatables-rowgroup-bs5/rowgroup.bootstrap5.scss',
22 23
 'resources/assets/vendor/libs/quill/typography.scss',
23 24
 'resources/assets/vendor/libs/quill/katex.scss',
24
-'resources/assets/vendor/libs/quill/editor.scss'
25
+'resources/assets/vendor/libs/quill/editor.scss',
26
+'resources/assets/vendor/libs/tagify/tagify.scss'
25 27
 ])
26 28
 
27 29
 @endsection
@@ -578,6 +580,18 @@ use App\Models\Config;
578 580
               <i class="bx bx-user me-1"></i>
579 581
               {{ $segnalazione->user ? $segnalazione->user->full_name : '---' }}
580 582
             </div>
583
+            @php
584
+              $ccVisibili = $segnalazione->emailDestinatari('cc');
585
+              $bccVisibili = $isVistaCliente ? [] : $segnalazione->emailDestinatari('bcc');
586
+            @endphp
587
+            @if($ccVisibili !== [])
588
+            <div class="small text-muted mb-1 mt-2">CC</div>
589
+            <div class="small text-break">{{ implode(', ', $ccVisibili) }}</div>
590
+            @endif
591
+            @if($bccVisibili !== [])
592
+            <div class="small text-muted mb-1 mt-2">CCN</div>
593
+            <div class="small text-break">{{ implode(', ', $bccVisibili) }}</div>
594
+            @endif
581 595
           </div>
582 596
 
583 597
           <div class="col-12 col-lg-3">
@@ -1406,6 +1420,12 @@ use App\Models\Config;
1406 1420
             </div>
1407 1421
             <div id="quill_annotazione" class="quill-annotazione-editor"></div>
1408 1422
             <div class="form-text mt-1 d-none" id="annotazioneQuoteHint">Citazione stile email già inserita (modificabile). Scrivi la nuova risposta sopra.</div>
1423
+            <div class="mt-3">
1424
+              @include('segnalazione.admin._cc_bcc_fields', [
1425
+                'ccValue' => old('cc', implode(',', \App\Models\Segnalazione::parseEmailList($segnalazione->cc ?? []))),
1426
+                'bccValue' => old('bcc', implode(',', \App\Models\Segnalazione::parseEmailList($segnalazione->bcc ?? []))),
1427
+              ])
1428
+            </div>
1409 1429
           </div>
1410 1430
 
1411 1431
           {{-- 4. Tempo / costo / allegati --}}
@@ -1583,6 +1603,31 @@ use App\Models\Config;
1583 1603
       }
1584 1604
     });
1585 1605
 
1606
+    function initSegnalazioneCcBccTagify() {
1607
+      if (typeof window.Tagify !== 'function') return;
1608
+      ['#segnalazione_cc', '#segnalazione_bcc'].forEach(function (sel) {
1609
+        const el = document.querySelector(sel);
1610
+        if (!el || el.dataset.tagifyReady) return;
1611
+        el.dataset.tagifyReady = '1';
1612
+        new window.Tagify(el, {
1613
+          delimiters: ',|;',
1614
+          duplicates: false,
1615
+          pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
1616
+          dropdown: { enabled: 0 },
1617
+          originalInputValueFormat: (values) => values.map((v) => v.value).join(',')
1618
+        });
1619
+      });
1620
+    }
1621
+
1622
+    function syncCcBccVisibility() {
1623
+      const riservata = $('#annotazione_riservata').is(':checked');
1624
+      $('#wrapSegnalazioneCcBcc').toggleClass('d-none', riservata);
1625
+    }
1626
+
1627
+    initSegnalazioneCcBccTagify();
1628
+    $('#annotazione_riservata').on('change', syncCcBccVisibility);
1629
+    syncCcBccVisibility();
1630
+
1586 1631
     const statiChiusura = @json(\App\Models\Config::getStatiSegnalazione('chiuse'));
1587 1632
 
1588 1633
     function incompleteObbligatoriTitles() {
@@ -2097,6 +2142,7 @@ use App\Models\Config;
2097 2142
       if ($riservata.length) {
2098 2143
         $riservata.prop('checked', !isRispondi);
2099 2144
       }
2145
+      syncCcBccVisibility();
2100 2146
       if ($label.length) {
2101 2147
         $label.text(isRispondi ? 'Rispondi alla segnalazione' : 'Modifica segnalazione');
2102 2148
       }

Notiek ielāde…
Atcelt
Saglabāt