S-13 sul modulo ufficiale e correzioni all'integrità dei dati

Modulo S-13
- Il PDF scrive sopra il modulo ufficiale (FPDI, matrice convertita in PDF 1.4)
  invece di ricostruirlo in HTML: 20 righe per foglio come l'originale.
- Una riga per ogni territorio attivo; riporto dell'ultimo rientro prima
  dell'anno; assegnazioni a cavallo d'anno su entrambi i fogli; scelta per
  data e non per anno registrato; nomi come "Cognome I.".
- Logica spostata in App\Services\Registro\ModuloS13.

Integrità dei dati
- Export/import XML (e quindi backup/ripristino): le date non slittano più di
  un giorno; i file vecchi in UTC tornano al giorno giusto.
- Import XML: rifiuta file estranei o incompleti senza toccare i dati, errore
  con riga e motivo, territori senza zona importati con il loro storico,
  caratteri di controllo rimossi in export, chi importa conserva password e
  ruolo di amministratore.
- Assegnazioni: assegnazioneCorrente corretta (filtro dentro ofMany), periodi
  sovrapposti rifiutati in Assegna e Registro sotto lock, anno di servizio
  ricavato dalla data, niente date future nel Registro.
- Utenti: non si declassa sé stessi né l'ultimo amministratore.
- Entrypoint: non sovrascrive più la copia persistente di APP_KEY con un
  valore diverso; serve APP_KEY_ROTATE=1 per un cambio voluto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:35:51 +02:00
parent 9c37c1ad7b
commit d433c9392d
17 changed files with 1361 additions and 457 deletions

View File

@@ -6,6 +6,7 @@ use App\Models\Assegnazione;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role;
@@ -17,6 +18,9 @@ class UsersIndex extends Component
public string $password = '';
public string $password_confirmation = '';
public string $selectedRole = '';
// Locked: è la lista che le regole di validazione usano per accettare un
// ruolo; modificabile dal client, ammetterebbe qualsiasi nome.
#[Locked]
public array $availableRoles = [];
public ?int $editingUserId = null;
@@ -115,6 +119,23 @@ class UsersIndex extends Component
$validated = $this->validate($this->editRules());
$user = User::query()->findOrFail($this->editingUserId);
// Togliere il ruolo di amministratore a sé stessi, o all'ultimo
// rimasto, lascia l'installazione senza nessuno che possa gestire
// utenti, impostazioni e backup.
if ($user->hasRole('amministratore') && $validated['editSelectedRole'] !== 'amministratore') {
if ($user->id === auth()->id()) {
$this->addError('editSelectedRole', 'Non puoi togliere a te stesso il ruolo di amministratore: chiedilo a un altro amministratore.');
return;
}
if (User::role('amministratore')->count() <= 1) {
$this->addError('editSelectedRole', "È l'ultimo amministratore: assegna prima il ruolo a un altro utente.");
return;
}
}
$user->name = $validated['editName'];
$user->email = $validated['editEmail'];

View File

@@ -241,11 +241,33 @@ class XmlExchange extends Component
$this->importStats = [];
$this->importIssues = [];
$xml = @simplexml_load_string($content);
if (! $xml) {
return ['error' => 'Impossibile leggere il file XML.'];
$xml = $this->leggiXml($content);
if (is_string($xml)) {
return ['error' => $xml];
}
// L'import parte svuotando le tabelle: un XML ben formato ma estraneo
// (un sitemap, l'export di un altro programma) lascerebbe
// l'installazione vuota con un "completato con successo". Si accetta
// solo un file prodotto da questa applicazione, con le sue sezioni.
if ($xml->getName() !== 'termanager2') {
return ['error' => 'Il file non è un export di TerManager2: l\'elemento principale è <' . $xml->getName() . '>, atteso <termanager2>. Nessun dato è stato modificato.'];
}
$mancanti = array_values(array_filter(
['territori', 'proclamatori', 'assegnazioni'],
fn (string $sezione) => ! isset($xml->{$sezione})
));
if ($mancanti !== []) {
return ['error' => 'Il file è incompleto: mancano le sezioni ' . implode(', ', $mancanti) . '. Nessun dato è stato modificato.'];
}
// Chi esegue l'import (o il ripristino, che imposta l'utente a mano)
// non deve uscirne estromesso: il file può contenere il suo account
// con una password vecchia o senza il ruolo di amministratore.
$attore = auth()->user();
$attoreEraAmministratore = $attore?->hasRole('amministratore') ?? false;
$actorId = auth()->id() ?? User::query()->value('id');
if (! $actorId) {
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
@@ -264,7 +286,8 @@ class XmlExchange extends Component
'assegnazioni_saltate' => 0,
];
DB::transaction(function () use ($xml, $actorId, &$stats) {
try {
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
Assegnazione::query()->delete();
Campagna::query()->delete();
Territorio::query()->withTrashed()->forceDelete();
@@ -334,6 +357,10 @@ class XmlExchange extends Component
$roleNames[] = $roleName;
}
}
$eAttore = $attore !== null && $user->id === $attore->id;
if ($eAttore && $attoreEraAmministratore && ! in_array('amministratore', $roleNames, true)) {
$roleNames[] = 'amministratore';
}
$user->syncRoles($roleNames);
// Password hash, remember_token, verification flag and
@@ -341,13 +368,17 @@ class XmlExchange extends Component
// hash is preserved exactly (the 'hashed' cast would re-validate
// the bcrypt cost and could reject a hash made with a different
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
// Per chi sta importando password e token restano i suoi:
// quelli del file possono essere di mesi fa.
$meta = [];
$passwordHash = (string) ($userNode->password ?? '');
if ($passwordHash !== '') {
if ($passwordHash !== '' && ! $eAttore) {
$meta['password'] = $passwordHash;
}
$rememberToken = (string) ($userNode->remember_token ?? '');
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
if (! $eAttore) {
$rememberToken = (string) ($userNode->remember_token ?? '');
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
}
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
@@ -415,8 +446,8 @@ class XmlExchange extends Component
$label = (string) $annoNode->label;
$anno = AnnoTeocratico::create([
'label' => $label,
'start_date' => (string) $annoNode->start_date,
'end_date' => (string) $annoNode->end_date,
'start_date' => $this->normalizeDateForDb((string) $annoNode->start_date, true),
'end_date' => $this->normalizeDateForDb((string) $annoNode->end_date, true),
]);
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
$stats['anni_importati']++;
@@ -430,8 +461,8 @@ class XmlExchange extends Component
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
$campagna = Campagna::create([
'descrizione' => (string) $campagnaNode->descrizione,
'start_date' => (string) $campagnaNode->start_date,
'end_date' => (string) $campagnaNode->end_date,
'start_date' => $this->normalizeDateForDb((string) $campagnaNode->start_date, true),
'end_date' => $this->normalizeDateForDb((string) $campagnaNode->end_date, true),
]);
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
$stats['campagne_importate']++;
@@ -455,9 +486,14 @@ class XmlExchange extends Component
continue;
}
if (!isset($zoneMap[$legacyZonaId], $tipologiaMap[$legacyTipologiaId])) {
$this->pushImportIssue('territorio', $legacyId, 'Riferimento zona/tipologia non trovato (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . ')');
continue;
// Zona e tipologia sono facoltative (le colonne sono nullable e
// un territorio resta senza quando la sua zona viene
// eliminata): un riferimento vuoto o sconosciuto non è motivo
// per scartare il territorio, che si porterebbe dietro tutto
// il suo storico di assegnazioni.
if (($legacyZonaId !== '' && ! isset($zoneMap[$legacyZonaId]))
|| ($legacyTipologiaId !== '' && ! isset($tipologiaMap[$legacyTipologiaId]))) {
$this->pushImportIssue('territorio', $legacyId, 'Zona o tipologia non trovata (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . '): territorio importato senza');
}
$pdfPath = (string) ($territorioNode->pdf_path ?? '');
@@ -465,8 +501,8 @@ class XmlExchange extends Component
$territorio = Territorio::create([
'numero' => $numero,
'zona_id' => $zoneMap[$legacyZonaId],
'tipologia_id' => $tipologiaMap[$legacyTipologiaId],
'zona_id' => $zoneMap[$legacyZonaId] ?? null,
'tipologia_id' => $tipologiaMap[$legacyTipologiaId] ?? null,
'confini' => (string) ($territorioNode->confini ?? ''),
'note' => (string) ($territorioNode->note ?? ''),
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
@@ -540,7 +576,15 @@ class XmlExchange extends Component
$this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode);
$stats['assegnazioni_importate']++;
}
});
});
} catch (\Illuminate\Database\QueryException $e) {
// La transazione è già stata annullata: il database è com'era.
// Senza questo l'amministratore vedrebbe un "Server Error" senza
// sapere quale dato del file ha violato un vincolo.
report($e);
return ['error' => 'Import annullato, nessun dato è stato modificato: il file contiene un dato che il database rifiuta (' . Str::limit($e->getPrevious()?->getMessage() ?? $e->getMessage(), 240) . ').'];
}
return [
'stats' => $stats,
@@ -581,16 +625,75 @@ class XmlExchange extends Component
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
])->all(),
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
'territori' => Territorio::query()->withTrashed()->orderBy('id')->get(['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
'assegnazioni' => Assegnazione::query()->orderBy('id')->get(['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at'])->toArray(),
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at']),
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at']),
];
}
/**
* Righe da esportare, con le date scritte in modo che il reimport le
* ritrovi identiche.
*
* toArray() serializza ogni data in UTC: con l'applicazione su
* Europe/Rome il 15/03 a mezzanotte diventava "2024-03-14T23:00:00Z" e
* al reimport tornava 14/03, un giorno perso a ogni backup e ripristino.
* Le colonne con cast date escono come Y-m-d, che non ha fuso; i
* timestamp come ISO 8601 con lo scostamento esplicito.
*
* @param array<int, string> $colonne
* @return array<int, array<string, mixed>>
*/
private function righeEsportate(\Illuminate\Database\Eloquent\Builder $query, array $colonne): array
{
return $query->get($colonne)->map(function (\Illuminate\Database\Eloquent\Model $riga) use ($colonne) {
$dati = [];
foreach ($colonne as $colonna) {
$valore = $riga->getAttribute($colonna);
if ($valore instanceof \Carbon\CarbonInterface) {
$valore = $riga->hasCast($colonna, ['date', 'immutable_date'])
? $valore->format('Y-m-d')
: $valore->toIso8601String();
}
$dati[$colonna] = $valore;
}
return $dati;
})->all();
}
/**
* Il file come SimpleXMLElement, oppure il messaggio d'errore con riga e
* motivo: "impossibile leggere il file" da solo non aiuta a correggerlo.
*/
private function leggiXml(string $content): \SimpleXMLElement|string
{
$precedente = libxml_use_internal_errors(true);
libxml_clear_errors();
try {
$xml = simplexml_load_string($content);
if ($xml !== false) {
return $xml;
}
$errore = libxml_get_errors()[0] ?? null;
return $errore
? sprintf('Impossibile leggere il file XML (riga %d): %s.', $errore->line, trim($errore->message))
: 'Impossibile leggere il file XML.';
} finally {
libxml_clear_errors();
libxml_use_internal_errors($precedente);
}
}
private function legacySqlToDataset(string $sql): array
{
$rows = $this->extractInsertRows($sql);
@@ -880,8 +983,12 @@ class XmlExchange extends Component
return $nullable ? null : null;
}
// Riportata nel fuso dell'applicazione prima di tenere solo il giorno:
// i backup fatti prima della correzione dell'export hanno le date in
// UTC ("2024-03-14T23:00:00Z" per il 15/03), e così tornano giuste.
// Una data senza orario e senza fuso resta com'è.
try {
return Carbon::parse($raw)->toDateString();
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateString();
} catch (\Throwable $e) {
return $nullable ? null : null;
}
@@ -1022,6 +1129,13 @@ class XmlExchange extends Component
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
{
// XML 1.0 non ammette i caratteri di controllo (tranne tab e a capo)
// né l'UTF-8 non valido: uno solo, magari incollato da Word in una
// nota, rende l'intero file illeggibile al reimport — e quel file è
// anche il data.xml dei backup.
$value = mb_scrub($value, 'UTF-8');
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? '';
$child = $parent->addChild($name);
$dom = dom_import_simplexml($child);
$dom->textContent = $value;
@@ -1062,7 +1176,7 @@ class XmlExchange extends Component
}
try {
return Carbon::parse($raw)->toDateTimeString();
return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateTimeString();
} catch (\Throwable $e) {
return null;
}