Roberto Santini 1 неделю назад
Родитель
Сommit
61c5e2269d

+ 49
- 1
app/Console/Commands/ImportaRedmine.php Просмотреть файл

27
                             {--user-fallback= : ID utente locale se non c\'è match}
27
                             {--user-fallback= : ID utente locale se non c\'è match}
28
                             {--tariffa= : €/ora per calcolare il costo dai tempi}
28
                             {--tariffa= : €/ora per calcolare il costo dai tempi}
29
                             {--contratto= : ID del contratto servizio da assegnare alle segnalazioni}
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
     protected $description = 'Importa issue, tempi, costi e allegati da Redmine 6.x. Senza progetto importa tutti i progetti e crea le aziende mancanti.';
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
             return $this->listProjects($client);
49
             return $this->listProjects($client);
49
         }
50
         }
50
 
51
 
52
+        if ($this->option('sync-cc')) {
53
+            return $this->syncCc($client);
54
+        }
55
+
51
         $projectArg = $this->argument('project');
56
         $projectArg = $this->argument('project');
52
         $aziendaArg = $this->argument('azienda');
57
         $aziendaArg = $this->argument('azienda');
53
 
58
 
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
      * @param  array<string, mixed>  $stats
482
      * @param  array<string, mixed>  $stats
435
      */
483
      */

+ 19
- 0
app/Http/Controllers/SegnalazioneController.php Просмотреть файл

165
       'slots.*.ora_fine' => 'nullable|date_format:H:i',
165
       'slots.*.ora_fine' => 'nullable|date_format:H:i',
166
       'allegato' => 'nullable|array',
166
       'allegato' => 'nullable|array',
167
       'allegato.*' => 'file|max:10240', // max 10MB per file
167
       'allegato.*' => 'file|max:10240', // max 10MB per file
168
+      'cc' => 'nullable|string',
169
+      'bcc' => 'nullable|string',
168
     ]);
170
     ]);
169
 
171
 
170
     if ($request->filled('ordine_lavoro_id') && $request->filled('contratto_servizio_id')) {
172
     if ($request->filled('ordine_lavoro_id') && $request->filled('contratto_servizio_id')) {
241
     $segnalazione->tempo_stimato = $request->filled('tempo_stimato')
243
     $segnalazione->tempo_stimato = $request->filled('tempo_stimato')
242
       ? round((float) $request->input('tempo_stimato'), 2)
244
       ? round((float) $request->input('tempo_stimato'), 2)
243
       : null;
245
       : null;
246
+    $this->applyCcBccFromRequest($segnalazione, $request);
244
     $segnalazione->save();
247
     $segnalazione->save();
245
 
248
 
246
     // Sync slot prenotazione (solo in stato pianificata; altrimenti svuota)
249
     // Sync slot prenotazione (solo in stato pianificata; altrimenti svuota)
319
       'descrizione' => 'required|string',
322
       'descrizione' => 'required|string',
320
       'allegato' => 'nullable|array',
323
       'allegato' => 'nullable|array',
321
       'allegato.*' => 'file|max:10240', // max 10MB per file
324
       'allegato.*' => 'file|max:10240', // max 10MB per file
325
+      'cc' => 'nullable|string',
326
+      'bcc' => 'nullable|string',
322
     ]);
327
     ]);
323
 
328
 
324
     $validator->after(function ($validator) use ($request) {
329
     $validator->after(function ($validator) use ($request) {
345
         $segnalazione->origine = 'manuale';
350
         $segnalazione->origine = 'manuale';
346
       }
351
       }
347
       $segnalazione->notificaApertaDa = $request->boolean('notifica_aperta_da');
352
       $segnalazione->notificaApertaDa = $request->boolean('notifica_aperta_da');
353
+      $this->applyCcBccFromRequest($segnalazione, $request);
348
       $categoriaSicurezzaValue = $request->input('categoria_sicurezza');
354
       $categoriaSicurezzaValue = $request->input('categoria_sicurezza');
349
       if ($categoriaSicurezzaValue !== null && $categoriaSicurezzaValue !== '') {
355
       if ($categoriaSicurezzaValue !== null && $categoriaSicurezzaValue !== '') {
350
         $tipoConfig = ConfiguraSegnalazione::where('gruppo', 'Tipo')
356
         $tipoConfig = ConfiguraSegnalazione::where('gruppo', 'Tipo')
813
         return $query->get();
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
     public function admin_delete(Request $request, $id)
835
     public function admin_delete(Request $request, $id)
817
     {
836
     {
818
       $segnalazione = Segnalazione::find($id);
837
       $segnalazione = Segnalazione::find($id);

+ 185
- 1
app/Models/Segnalazione.php Просмотреть файл

17
   /** Flag runtime: non notificare il cliente (Aperta da) su questo salvataggio. */
17
   /** Flag runtime: non notificare il cliente (Aperta da) su questo salvataggio. */
18
   public bool $skipNotificaCliente = false;
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
   // public function getCasts()
36
   // public function getCasts()
21
   // {
37
   // {
22
   //   return array_merge(parent::getCasts(), [
38
   //   return array_merge(parent::getCasts(), [
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
     public function notifyApertaDaAnnotazionePubblica(Annotazione $annotazione): void
594
     public function notifyApertaDaAnnotazionePubblica(Annotazione $annotazione): void
411
     {
595
     {

+ 5
- 1
app/Notifications/AnnotazionePubblicaApertaDa.php Просмотреть файл

45
             ? $this->annotazione->user->full_name
45
             ? $this->annotazione->user->full_name
46
             : trim((string) ($this->annotazione->riferimento ?: 'Operatore'));
46
             : trim((string) ($this->annotazione->riferimento ?: 'Operatore'));
47
 
47
 
48
-        return (new MailMessage)
48
+        $mail = (new MailMessage)
49
             ->subject($this->segnalazione->mailSubject(true))
49
             ->subject($this->segnalazione->mailSubject(true))
50
             ->markdown('mail.annotazione-pubblica-aperta-da', [
50
             ->markdown('mail.annotazione-pubblica-aperta-da', [
51
                 'segnalazione' => $this->segnalazione,
51
                 'segnalazione' => $this->segnalazione,
56
                 'prioritaLabel' => $prioritaLabel,
56
                 'prioritaLabel' => $prioritaLabel,
57
                 'autoreLabel' => $autoreLabel,
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 Просмотреть файл

77
             report($e);
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
         return $mail;
86
         return $mail;
81
     }
87
     }
82
 
88
 

+ 18
- 1
app/Notifications/RispostaAutoSegnalazioneEmail.php Просмотреть файл

32
         $body = Config::testoRispostaAutoEmail($this->segnalazione);
32
         $body = Config::testoRispostaAutoEmail($this->segnalazione);
33
         $paragraphs = preg_split("/\R{2,}/", trim($body)) ?: [];
33
         $paragraphs = preg_split("/\R{2,}/", trim($body)) ?: [];
34
 
34
 
35
-        return (new MailMessage)
35
+        $mail = (new MailMessage)
36
             ->subject($this->segnalazione->mailSubject(true))
36
             ->subject($this->segnalazione->mailSubject(true))
37
             ->markdown('mail.risposta-auto-segnalazione-email', [
37
             ->markdown('mail.risposta-auto-segnalazione-email', [
38
                 'segnalazione' => $this->segnalazione,
38
                 'segnalazione' => $this->segnalazione,
41
                     $paragraphs
41
                     $paragraphs
42
                 ), static fn ($p) => $p !== '')),
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 Просмотреть файл

241
         return is_array($data['helpdesk_ticket'] ?? null) ? $data['helpdesk_ticket'] : null;
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
      * @return array<string, mixed>|null
307
      * @return array<string, mixed>|null
246
      */
308
      */

+ 96
- 0
app/Services/Redmine/RedmineImportService.php Просмотреть файл

412
             'contact_id' => $this->lastContactMeta['id'] ?? null,
412
             'contact_id' => $this->lastContactMeta['id'] ?? null,
413
             'helpdesk_from' => $this->lastContactMeta['email'] ?? null,
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
         $segnalazione->meta = $meta;
422
         $segnalazione->meta = $meta;
416
         Segnalazione::withoutAuditing(function () use ($segnalazione) {
423
         Segnalazione::withoutAuditing(function () use ($segnalazione) {
417
             $segnalazione->save();
424
             $segnalazione->save();
1044
         return is_array($cached) ? $cached : null;
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
     protected function cachedContact(int $contactId): ?array
1143
     protected function cachedContact(int $contactId): ?array
1048
     {
1144
     {
1049
         if (! array_key_exists($contactId, $this->contactCache)) {
1145
         if (! array_key_exists($contactId, $this->contactCache)) {

+ 62
- 0
app/Services/SegnalazioneEmailInboxService.php Просмотреть файл

251
             $created = true;
251
             $created = true;
252
         }
252
         }
253
 
253
 
254
+        $this->syncCcFromMessage($segnalazione, $message, $fromAddress);
255
+
254
         $annotazione = $this->createAnnotazioneFromMessage($segnalazione, $message, $fromAddress, ! $created);
256
         $annotazione = $this->createAnnotazioneFromMessage($segnalazione, $message, $fromAddress, ! $created);
255
         $this->storeAttachments($segnalazione, $annotazione, $message);
257
         $this->storeAttachments($segnalazione, $annotazione, $message);
256
 
258
 
841
         return $personal !== '' ? $personal : ($first->mail ? (string) $first->mail : null);
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
     protected function alreadyImported(Segnalazione $segnalazione, string $messageId): bool
906
     protected function alreadyImported(Segnalazione $segnalazione, string $messageId): bool
845
     {
907
     {
846
         $meta = is_array($segnalazione->meta) ? $segnalazione->meta : [];
908
         $meta = is_array($segnalazione->meta) ? $segnalazione->meta : [];

+ 23
- 0
database/migrations/2026_09_08_090000_add_cc_bcc_to_segnalazione.php Просмотреть файл

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 Просмотреть файл

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 Просмотреть файл

17
 @vite([
17
 @vite([
18
 'resources/assets/vendor/libs/datatables-bs5/datatables-bootstrap5.js',
18
 'resources/assets/vendor/libs/datatables-bs5/datatables-bootstrap5.js',
19
 'resources/assets/vendor/libs/quill/katex.js',
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
 @endsection
23
 @endsection
23
 
24
 
26
 'resources/assets/vendor/libs/datatables-rowgroup-bs5/rowgroup.bootstrap5.scss',
27
 'resources/assets/vendor/libs/datatables-rowgroup-bs5/rowgroup.bootstrap5.scss',
27
 'resources/assets/vendor/libs/quill/typography.scss',
28
 'resources/assets/vendor/libs/quill/typography.scss',
28
 'resources/assets/vendor/libs/quill/katex.scss',
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
 @endsection
33
 @endsection
32
 
34
 
112
           </div>
114
           </div>
113
         </div>
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
         <div class="row g-3 mb-4 align-items-start" id="rowOrdineContratto">
124
         <div class="row g-3 mb-4 align-items-start" id="rowOrdineContratto">
116
           <div class="col-12 col-lg-6" id="wrapOrdineLavoro">
125
           <div class="col-12 col-lg-6" id="wrapOrdineLavoro">
117
             <div class="row g-2 align-items-stretch">
126
             <div class="row g-2 align-items-stretch">
286
 @section('page-script')
295
 @section('page-script')
287
 <script type="module">
296
 <script type="module">
288
   $(document).ready(function() {
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
     function initQuillEditor(editorId, toolbarId, hiddenInputId) {
312
     function initQuillEditor(editorId, toolbarId, hiddenInputId) {
290
       const editor = new Quill('#' + editorId, {
313
       const editor = new Quill('#' + editorId, {
291
         bounds: '#' + editorId,
314
         bounds: '#' + editorId,

+ 48
- 2
resources/views/segnalazione/admin/show.blade.php Просмотреть файл

12
 @vite([
12
 @vite([
13
 'resources/assets/vendor/libs/datatables-bs5/datatables-bootstrap5.js',
13
 'resources/assets/vendor/libs/datatables-bs5/datatables-bootstrap5.js',
14
 'resources/assets/vendor/libs/quill/katex.js',
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
 @endsection
18
 @endsection
18
 
19
 
21
 'resources/assets/vendor/libs/datatables-rowgroup-bs5/rowgroup.bootstrap5.scss',
22
 'resources/assets/vendor/libs/datatables-rowgroup-bs5/rowgroup.bootstrap5.scss',
22
 'resources/assets/vendor/libs/quill/typography.scss',
23
 'resources/assets/vendor/libs/quill/typography.scss',
23
 'resources/assets/vendor/libs/quill/katex.scss',
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
 @endsection
29
 @endsection
578
               <i class="bx bx-user me-1"></i>
580
               <i class="bx bx-user me-1"></i>
579
               {{ $segnalazione->user ? $segnalazione->user->full_name : '---' }}
581
               {{ $segnalazione->user ? $segnalazione->user->full_name : '---' }}
580
             </div>
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
           </div>
595
           </div>
582
 
596
 
583
           <div class="col-12 col-lg-3">
597
           <div class="col-12 col-lg-3">
1406
             </div>
1420
             </div>
1407
             <div id="quill_annotazione" class="quill-annotazione-editor"></div>
1421
             <div id="quill_annotazione" class="quill-annotazione-editor"></div>
1408
             <div class="form-text mt-1 d-none" id="annotazioneQuoteHint">Citazione stile email già inserita (modificabile). Scrivi la nuova risposta sopra.</div>
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
           </div>
1429
           </div>
1410
 
1430
 
1411
           {{-- 4. Tempo / costo / allegati --}}
1431
           {{-- 4. Tempo / costo / allegati --}}
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
     const statiChiusura = @json(\App\Models\Config::getStatiSegnalazione('chiuse'));
1631
     const statiChiusura = @json(\App\Models\Config::getStatiSegnalazione('chiuse'));
1587
 
1632
 
1588
     function incompleteObbligatoriTitles() {
1633
     function incompleteObbligatoriTitles() {
2097
       if ($riservata.length) {
2142
       if ($riservata.length) {
2098
         $riservata.prop('checked', !isRispondi);
2143
         $riservata.prop('checked', !isRispondi);
2099
       }
2144
       }
2145
+      syncCcBccVisibility();
2100
       if ($label.length) {
2146
       if ($label.length) {
2101
         $label.text(isRispondi ? 'Rispondi alla segnalazione' : 'Modifica segnalazione');
2147
         $label.text(isRispondi ? 'Rispondi alla segnalazione' : 'Modifica segnalazione');
2102
       }
2148
       }

Загрузка…
Отмена
Сохранить