++ fix import e permessi

This commit is contained in:
Francesco Picone
2026-07-25 22:39:38 +02:00
parent 3a530704a2
commit 9421891989
9 changed files with 375 additions and 33 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@
/storage/app/livewire-tmp/*.* /storage/app/livewire-tmp/*.*
/storage/logs/ /storage/logs/
/storage/framework/ /storage/framework/
/storage/app/public/territori-thumbnails/*.png
/bootstrap/cache/ /bootstrap/cache/
/public/hot /public/hot
/public/storage /public/storage

View File

@@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
può dare: perché le cose sono come sono, e cosa resta aperto. può dare: perché le cose sono come sono, e cosa resta aperto.
Ultimo aggiornamento: **18 luglio 2026**. Ultimo aggiornamento: **25 luglio 2026**.
--- ---
@@ -89,6 +89,66 @@ messaggio d'errore è più chiaro).
Deliberato: un errore immediato è meglio di un database in produzione con Deliberato: un errore immediato è meglio di un database in produzione con
password `secret`. password `secret`.
### Modifica/cancellazione delle assegnazioni: solo amministratore
L'unico punto in cui un'assegnazione **esistente** si modifica o si cancella è
il CRUD del Registro (`Livewire/Registro`: `openCreate`/`openEdit`/`save`/
`askDelete`/`deleteConfirmed`), blindato su `settings.manage` a due livelli —
`abort_if(..., 403)` nel componente e `@can('settings.manage')` in
`registro.blade.php`, che nasconde pulsanti e modali. `settings.manage` ce l'ha
solo l'`amministratore`.
Di conseguenza gli altri ruoli **non** possono alterare le assegnazioni: la
pagina Assegna solo ne crea (`territori.assign`), la pagina Rientra solo le
chiude (`territori.return`). Nello specifico l'`assistente` (`registro.view` +
`territori.assign` + `territori.return`) vede il Registro, crea nuove
assegnazioni e le fa rientrare, ma non le modifica né le elimina.
Poiché il seeder concede in modo additivo e non revoca mai (vedi la nota su
`syncPermissions()` più sotto), un permesso `settings.manage` concesso a mano
all'`assistente` in passato sopravviverebbe a ogni re-seed. La migration
`2026_07_25_000001_revoke_settings_manage_from_assistente_role` chiude questo
drift per i database esistenti: revoca `settings.manage` dal ruolo `assistente`
se presente. È idempotente (no-op sul baseline) e volutamente irreversibile —
il `down()` non ripristina il permesso.
### Export/Import XML: backup completo e round-trip
`Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali,
non più un sottoinsieme. L'export (`currentDataset` + `datasetToXml`) include
ora: utenti (con hash password, ruoli, `email_verified_at`, `remember_token`),
i campi settings prima omessi (`public_base_url`, `logo_path`,
`pdf_viewer_show_download`, `setup_completed`), lo stato soft-delete
(`deleted_at`) di proclamatori/territori, i path file dei territori
(`pdf_path`, `thumbnail_path`), le FK di audit delle assegnazioni
(`created_by`/`returned_by`, esportate come `legacy_*_id`), `link_sent`,
`pdf_access_code`, e i timestamp `created_at`/`updated_at` di ogni entità.
L'import (`importXmlFromContent`) legge simmetricamente tutti questi campi, così
un export si reimporta senza perdite.
Tre scelte non ovvie:
- **`legacy_id` derivato dall'`id` reale.** Il rimappaggio delle FK in import si
basa sull'attributo `legacy_id`. Prima veniva scritto solo dalla conversione
SQL legacy: l'export dell'app corrente ne era privo, quindi un suo re-import
**perdeva territori e assegnazioni** (riferimenti non risolti). Ora
`addLegacyIdAttribute()` usa `legacy_id` se presente, altrimenti l'`id` reale.
- **Gli utenti sono UPSERT per email, non azzerati.** Tutte le altre entità
vengono cancellate e ricreate; gli utenti no. Azzerare la tabella `users`
durante un restore rischierebbe di **estromettere l'amministratore** che sta
importando (backup che precede il suo account, o file parziale). Gli account
esistenti vengono aggiornati, quelli nel file (ri)creati; nessuno viene
eliminato. Conseguenza: non è un mirror perfetto (utenti extra restano), ma è
senza perdite e non blocca fuori nessuno.
- **Hash password e timestamp scritti col query builder.** Dopo il `save()` del
modello, password/`remember_token`/`email_verified_at`/`created_at`/
`updated_at` vengono scritti con `DB::table()->update()`: preserva l'hash
esatto (il cast `hashed` rivaliderebbe il costo bcrypt e potrebbe rifiutare un
hash generato con `BCRYPT_ROUNDS` diverso) ed evita che Eloquent sovrascriva i
timestamp importati. Stessa tecnica in `applyRowMeta()` per le altre entità.
### Un solo container esegue le migrazioni ### Un solo container esegue le migrazioni
Solo `app` migra. `queue-worker` e `scheduler` usano `queue-entrypoint.sh`, che Solo `app` migra. `queue-worker` e `scheduler` usano `queue-entrypoint.sh`, che
@@ -120,6 +180,7 @@ Contesto utile se ti imbatti in comportamenti che sembrano regressioni.
| `pdftoppm` senza timeout | Un PDF malformato bloccava il worker **per sempre** | `TerritorioThumbnailService` | | `pdftoppm` senza timeout | Un PDF malformato bloccava il worker **per sempre** | `TerritorioThumbnailService` |
| Collisione nomi file PDF | `1 A` e `1-A` scrivevano sullo stesso file → dati sovrascritti | `Jobs/ImportTerritoryPdfFolder` | | Collisione nomi file PDF | `1 A` e `1-A` scrivevano sullo stesso file → dati sovrascritti | `Jobs/ImportTerritoryPdfFolder` |
| Zip senza limiti | Zip bomb → esaurimento memoria | `TerritorioPdfImportDispatcher` | | Zip senza limiti | Zip bomb → esaurimento memoria | `TerritorioPdfImportDispatcher` |
| Export XML senza `legacy_id` | Il re-import dell'export dell'app corrente perdeva territori e assegnazioni (FK non risolte) | `Livewire/Settings/XmlExchange` (`addLegacyIdAttribute`) |
| Migrazioni thumbnail duplicate | `migrate:rollback` falliva a metà | rimosse 2 migrazioni ridondanti | | Migrazioni thumbnail duplicate | `migrate:rollback` falliva a metà | rimosse 2 migrazioni ridondanti |
--- ---
@@ -191,6 +252,12 @@ miniature e la chiave di fallback. Non va trattato come rigenerabile.
**Modifiche a dipendenze o asset richiedono un rebuild.** Non è più sufficiente **Modifiche a dipendenze o asset richiedono un rebuild.** Non è più sufficiente
riavviare il container: `vendor/` e `public/build` vivono dentro l'immagine. riavviare il container: `vendor/` e `public/build` vivono dentro l'immagine.
**L'export XML contiene dati sensibili.** Il file di export include gli hash
delle password di tutti gli utenti e i nomi/cognomi dei proclamatori in chiaro.
Va trattato come un segreto: chi lo ottiene può tentare il crack offline degli
hash. La pagina di export lo segnala, ma il controllo resta umano — la
funzione è già riservata all'amministratore (`settings.manage`).
--- ---
## Come verificare che tutto funzioni ## Come verificare che tutto funzioni

View File

@@ -181,6 +181,7 @@ class XmlExchange extends Component
} }
$stats = [ $stats = [
'utenti_importati' => 0,
'zone_importate' => 0, 'zone_importate' => 0,
'tipologie_importate' => 0, 'tipologie_importate' => 0,
'proclamatori_importati' => 0, 'proclamatori_importati' => 0,
@@ -212,22 +213,96 @@ class XmlExchange extends Component
if ($settingsNode) { if ($settingsNode) {
Setting::instance()->update([ Setting::instance()->update([
'congregazione_nome' => (string) ($settingsNode->congregazione_nome ?? ''), 'congregazione_nome' => (string) ($settingsNode->congregazione_nome ?? ''),
'public_base_url' => (string) ($settingsNode->public_base_url ?? ''),
'logo_path' => (string) ($settingsNode->logo_path ?? ''),
'giorni_giacenza_da_assegnare' => (int) ($settingsNode->giorni_giacenza_da_assegnare ?? 120), 'giorni_giacenza_da_assegnare' => (int) ($settingsNode->giorni_giacenza_da_assegnare ?? 120),
'giorni_giacenza_prioritari' => (int) ($settingsNode->giorni_giacenza_prioritari ?? 180), 'giorni_giacenza_prioritari' => (int) ($settingsNode->giorni_giacenza_prioritari ?? 180),
'giorni_per_smarrito' => (int) ($settingsNode->giorni_per_smarrito ?? 120), 'giorni_per_smarrito' => (int) ($settingsNode->giorni_per_smarrito ?? 120),
'home_limit_list' => (int) ($settingsNode->home_limit_list ?? 10), 'home_limit_list' => (int) ($settingsNode->home_limit_list ?? 10),
'assignment_link_ttl_hours' => (int) ($settingsNode->assignment_link_ttl_months ?? $settingsNode->assignment_link_ttl_hours ?? 1), 'assignment_link_ttl_hours' => (int) ($settingsNode->assignment_link_ttl_months ?? $settingsNode->assignment_link_ttl_hours ?? 1),
'pdf_viewer_show_download' => ((int) ($settingsNode->pdf_viewer_show_download ?? 0)) === 1,
'audit_retention_days' => (int) ($settingsNode->audit_retention_days ?? 730), 'audit_retention_days' => (int) ($settingsNode->audit_retention_days ?? 730),
// The first-run wizard was removed; an imported dataset always
// represents a configured install, so setup is complete.
'setup_completed' => true, 'setup_completed' => true,
]); ]);
} }
// Users are UPSERTED by email, never wiped. Deleting the users
// table during a restore risks locking out the administrator
// performing the import (e.g. a backup that predates their account,
// or a partial file). Existing accounts are updated in place;
// accounts in the file are (re)created. $userMap remaps the
// created_by/returned_by FKs on the assignments below.
$userMap = [];
foreach (($xml->users->user ?? []) as $userNode) {
$email = trim((string) ($userNode->email ?? ''));
$legacyId = (string) ($userNode['legacy_id'] ?? '');
if ($email === '') {
$this->pushImportIssue('utente', $legacyId, 'Email mancante');
continue;
}
$user = User::query()->firstOrNew(['email' => $email]);
$user->name = (string) ($userNode->name ?? $email);
if (! $user->exists) {
// password is NOT NULL; the real hash is written raw below.
$user->password = Str::password(24);
}
$user->save();
// Ensure every referenced role exists before syncing — the
// standard three come from the seeder, but a custom role in the
// backup would otherwise make syncRoles() throw.
$roleNames = [];
foreach (($userNode->roles->role ?? []) as $roleNode) {
$roleName = trim((string) $roleNode);
if ($roleName !== '') {
\Spatie\Permission\Models\Role::firstOrCreate(['name' => $roleName, 'guard_name' => 'web']);
$roleNames[] = $roleName;
}
}
$user->syncRoles($roleNames);
// Password hash, remember_token, verification flag and
// timestamps are written with the query builder: the raw stored
// 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.
$meta = [];
$passwordHash = (string) ($userNode->password ?? '');
if ($passwordHash !== '') {
$meta['password'] = $passwordHash;
}
$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 ?? ''));
if ($createdAt !== null) {
$meta['created_at'] = $createdAt;
}
$updatedAt = $this->normalizeDateTimeForDb((string) ($userNode->updated_at ?? ''));
if ($updatedAt !== null) {
$meta['updated_at'] = $updatedAt;
}
DB::table('users')->where('id', $user->id)->update($meta);
$stats['utenti_importati']++;
if ($legacyId !== '') {
$userMap[$legacyId] = $user->id;
}
}
$zoneMap = []; $zoneMap = [];
foreach (($xml->zones->zone ?? []) as $zoneNode) { foreach (($xml->zones->zone ?? []) as $zoneNode) {
$z = Zona::create([ $z = Zona::create([
'nome' => (string) $zoneNode->nome, 'nome' => (string) $zoneNode->nome,
'attivo' => ((int) ($zoneNode->attivo ?? 1)) === 1, 'attivo' => ((int) ($zoneNode->attivo ?? 1)) === 1,
]); ]);
$this->applyRowMeta('zone', $z->id, $zoneNode);
$stats['zone_importate']++; $stats['zone_importate']++;
$legacyId = (string) ($zoneNode['legacy_id'] ?? ''); $legacyId = (string) ($zoneNode['legacy_id'] ?? '');
if ($legacyId !== '') { if ($legacyId !== '') {
@@ -241,6 +316,7 @@ class XmlExchange extends Component
'nome' => (string) $tipologiaNode->nome, 'nome' => (string) $tipologiaNode->nome,
'attivo' => ((int) ($tipologiaNode->attivo ?? 1)) === 1, 'attivo' => ((int) ($tipologiaNode->attivo ?? 1)) === 1,
]); ]);
$this->applyRowMeta('tipologie', $t->id, $tipologiaNode);
$stats['tipologie_importate']++; $stats['tipologie_importate']++;
$legacyId = (string) ($tipologiaNode['legacy_id'] ?? ''); $legacyId = (string) ($tipologiaNode['legacy_id'] ?? '');
if ($legacyId !== '') { if ($legacyId !== '') {
@@ -255,6 +331,7 @@ class XmlExchange extends Component
'cognome' => (string) $proclamatoreNode->cognome, 'cognome' => (string) $proclamatoreNode->cognome,
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1, 'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
]); ]);
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
$stats['proclamatori_importati']++; $stats['proclamatori_importati']++;
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? ''); $legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
if ($legacyId !== '') { if ($legacyId !== '') {
@@ -270,6 +347,7 @@ class XmlExchange extends Component
'start_date' => (string) $annoNode->start_date, 'start_date' => (string) $annoNode->start_date,
'end_date' => (string) $annoNode->end_date, 'end_date' => (string) $annoNode->end_date,
]); ]);
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
$stats['anni_importati']++; $stats['anni_importati']++;
$legacyId = (string) ($annoNode['legacy_id'] ?? ''); $legacyId = (string) ($annoNode['legacy_id'] ?? '');
if ($legacyId !== '') { if ($legacyId !== '') {
@@ -284,6 +362,7 @@ class XmlExchange extends Component
'start_date' => (string) $campagnaNode->start_date, 'start_date' => (string) $campagnaNode->start_date,
'end_date' => (string) $campagnaNode->end_date, 'end_date' => (string) $campagnaNode->end_date,
]); ]);
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
$stats['campagne_importate']++; $stats['campagne_importate']++;
$legacyId = (string) ($campagnaNode['legacy_id'] ?? ''); $legacyId = (string) ($campagnaNode['legacy_id'] ?? '');
if ($legacyId !== '') { if ($legacyId !== '') {
@@ -310,6 +389,9 @@ class XmlExchange extends Component
continue; continue;
} }
$pdfPath = (string) ($territorioNode->pdf_path ?? '');
$thumbnailPath = (string) ($territorioNode->thumbnail_path ?? '');
$territorio = Territorio::create([ $territorio = Territorio::create([
'numero' => $numero, 'numero' => $numero,
'zona_id' => $zoneMap[$legacyZonaId], 'zona_id' => $zoneMap[$legacyZonaId],
@@ -318,7 +400,10 @@ class XmlExchange extends Component
'note' => (string) ($territorioNode->note ?? ''), 'note' => (string) ($territorioNode->note ?? ''),
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1, 'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1, 'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
'pdf_path' => $pdfPath !== '' ? $pdfPath : null,
'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null,
]); ]);
$this->applyRowMeta('territori', $territorio->id, $territorioNode, true);
$territoriNumeroVisti[$numero] = true; $territoriNumeroVisti[$numero] = true;
$stats['territori_importati']++; $stats['territori_importati']++;
@@ -350,7 +435,24 @@ class XmlExchange extends Component
$legacyCampagna = (string) ($assegnazioneNode->legacy_campagna_id ?? ''); $legacyCampagna = (string) ($assegnazioneNode->legacy_campagna_id ?? '');
Assegnazione::create([ // Remap the audit FKs onto the imported users; fall back to the
// acting user when the referenced account isn't in the file.
$legacyCreatedBy = (string) ($assegnazioneNode->legacy_created_by_id ?? '');
$createdBy = ($legacyCreatedBy !== '' && isset($userMap[$legacyCreatedBy]))
? $userMap[$legacyCreatedBy]
: $actorId;
$returnedBy = null;
if ($returnedAt !== null) {
$legacyReturnedBy = (string) ($assegnazioneNode->legacy_returned_by_id ?? '');
$returnedBy = ($legacyReturnedBy !== '' && isset($userMap[$legacyReturnedBy]))
? $userMap[$legacyReturnedBy]
: $actorId;
}
$pdfAccessCode = (string) ($assegnazioneNode->pdf_access_code ?? '');
$assegnazione = Assegnazione::create([
'territorio_id' => $territorioMap[$legacyTerritorio], 'territorio_id' => $territorioMap[$legacyTerritorio],
'proclamatore_id' => $proclamatoreMap[$legacyProclamatore], 'proclamatore_id' => $proclamatoreMap[$legacyProclamatore],
'anno_teocratico_id' => $annoMap[$legacyAnno], 'anno_teocratico_id' => $annoMap[$legacyAnno],
@@ -358,10 +460,13 @@ class XmlExchange extends Component
'returned_at' => $returnedAt, 'returned_at' => $returnedAt,
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1, 'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null, 'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
'note' => (string) ($assegnazioneNode->note ?? ''), 'note' => (string) ($assegnazioneNode->note ?? ''),
'created_by' => $actorId, 'created_by' => $createdBy,
'returned_by' => $returnedAt !== null ? $actorId : null, 'returned_by' => $returnedBy,
]); ]);
$this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode);
$stats['assegnazioni_importate']++; $stats['assegnazioni_importate']++;
} }
}); });
@@ -379,20 +484,39 @@ class XmlExchange extends Component
return [ return [
'settings' => [ 'settings' => [
'congregazione_nome' => (string) ($settings->congregazione_nome ?? ''), 'congregazione_nome' => (string) ($settings->congregazione_nome ?? ''),
'public_base_url' => (string) ($settings->public_base_url ?? ''),
'logo_path' => (string) ($settings->logo_path ?? ''),
'giorni_giacenza_da_assegnare' => (int) ($settings->giorni_giacenza_da_assegnare ?? 120), 'giorni_giacenza_da_assegnare' => (int) ($settings->giorni_giacenza_da_assegnare ?? 120),
'giorni_giacenza_prioritari' => (int) ($settings->giorni_giacenza_prioritari ?? 180), 'giorni_giacenza_prioritari' => (int) ($settings->giorni_giacenza_prioritari ?? 180),
'giorni_per_smarrito' => (int) ($settings->giorni_per_smarrito ?? 120), 'giorni_per_smarrito' => (int) ($settings->giorni_per_smarrito ?? 120),
'home_limit_list' => (int) ($settings->home_limit_list ?? 10), 'home_limit_list' => (int) ($settings->home_limit_list ?? 10),
'assignment_link_ttl_months' => (int) ($settings->assignment_link_ttl_hours ?? 1), 'assignment_link_ttl_months' => (int) ($settings->assignment_link_ttl_hours ?? 1),
'pdf_viewer_show_download' => (int) ((bool) ($settings->pdf_viewer_show_download ?? false)),
'audit_retention_days' => (int) ($settings->audit_retention_days ?? 730), 'audit_retention_days' => (int) ($settings->audit_retention_days ?? 730),
'setup_completed' => (int) ((bool) ($settings->setup_completed ?? true)),
], ],
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(), // Full user rows, credentials included. getAuthPassword() bypasses
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(), // the $hidden guard to read the raw bcrypt hash; roles come from the
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo'])->toArray(), // Spatie relation. This is sensitive data — see the note in the
'territori' => Territorio::query()->withTrashed()->orderBy('id')->get(['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario'])->toArray(), // export UI / HANDOFF.
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date'])->toArray(), 'users' => User::query()->with('roles')->orderBy('id')->get()->map(fn ($u) => [
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date'])->toArray(), 'legacy_id' => (string) $u->id,
'assegnazioni' => Assegnazione::query()->orderBy('id')->get(['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note'])->toArray(), 'name' => (string) $u->name,
'email' => (string) $u->email,
'password' => (string) $u->getAuthPassword(),
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
'remember_token' => (string) ($u->getRememberToken() ?? ''),
'roles' => $u->roles->pluck('name')->all(),
'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(),
]; ];
} }
@@ -717,43 +841,59 @@ class XmlExchange extends Component
$this->addXmlText($settings, $key, (string) $value); $this->addXmlText($settings, $key, (string) $value);
} }
// Users are absent from the legacy-SQL dataset, so guard with ?? [].
$usersNode = $xml->addChild('users');
foreach (($dataset['users'] ?? []) as $user) {
$node = $usersNode->addChild('user');
if (isset($user['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $user['legacy_id']);
}
$this->addXmlText($node, 'name', (string) ($user['name'] ?? ''));
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$this->addXmlText($node, 'remember_token', (string) ($user['remember_token'] ?? ''));
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
$rolesNode = $node->addChild('roles');
foreach (($user['roles'] ?? []) as $roleName) {
$this->addXmlText($rolesNode, 'role', (string) $roleName);
}
}
$zonesNode = $xml->addChild('zones'); $zonesNode = $xml->addChild('zones');
foreach ($dataset['zones'] as $zone) { foreach ($dataset['zones'] as $zone) {
$node = $zonesNode->addChild('zone'); $node = $zonesNode->addChild('zone');
if (isset($zone['legacy_id'])) { $this->addLegacyIdAttribute($node, $zone);
$node->addAttribute('legacy_id', (string) $zone['legacy_id']);
}
$this->addXmlText($node, 'nome', (string) ($zone['nome'] ?? '')); $this->addXmlText($node, 'nome', (string) ($zone['nome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($zone['attivo'] ?? 1))); $node->addChild('attivo', (string) ((int) ($zone['attivo'] ?? 1)));
$this->addTimestampNodes($node, $zone);
} }
$tipologieNode = $xml->addChild('tipologie'); $tipologieNode = $xml->addChild('tipologie');
foreach ($dataset['tipologie'] as $tipologia) { foreach ($dataset['tipologie'] as $tipologia) {
$node = $tipologieNode->addChild('tipologia'); $node = $tipologieNode->addChild('tipologia');
if (isset($tipologia['legacy_id'])) { $this->addLegacyIdAttribute($node, $tipologia);
$node->addAttribute('legacy_id', (string) $tipologia['legacy_id']);
}
$this->addXmlText($node, 'nome', (string) ($tipologia['nome'] ?? '')); $this->addXmlText($node, 'nome', (string) ($tipologia['nome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($tipologia['attivo'] ?? 1))); $node->addChild('attivo', (string) ((int) ($tipologia['attivo'] ?? 1)));
$this->addTimestampNodes($node, $tipologia);
} }
$proclamatoriNode = $xml->addChild('proclamatori'); $proclamatoriNode = $xml->addChild('proclamatori');
foreach ($dataset['proclamatori'] as $proclamatore) { foreach ($dataset['proclamatori'] as $proclamatore) {
$node = $proclamatoriNode->addChild('proclamatore'); $node = $proclamatoriNode->addChild('proclamatore');
if (isset($proclamatore['legacy_id'])) { $this->addLegacyIdAttribute($node, $proclamatore);
$node->addAttribute('legacy_id', (string) $proclamatore['legacy_id']);
}
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? '')); $this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? '')); $this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1))); $node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
$this->addTimestampNodes($node, $proclamatore);
} }
$territoriNode = $xml->addChild('territori'); $territoriNode = $xml->addChild('territori');
foreach ($dataset['territori'] as $territorio) { foreach ($dataset['territori'] as $territorio) {
$node = $territoriNode->addChild('territorio'); $node = $territoriNode->addChild('territorio');
if (isset($territorio['legacy_id'])) { $this->addLegacyIdAttribute($node, $territorio);
$node->addAttribute('legacy_id', (string) $territorio['legacy_id']);
}
$this->addXmlText($node, 'numero', (string) ($territorio['numero'] ?? '')); $this->addXmlText($node, 'numero', (string) ($territorio['numero'] ?? ''));
$node->addChild('legacy_zona_id', (string) ($territorio['legacy_zona_id'] ?? ($territorio['zona_id'] ?? ''))); $node->addChild('legacy_zona_id', (string) ($territorio['legacy_zona_id'] ?? ($territorio['zona_id'] ?? '')));
$node->addChild('legacy_tipologia_id', (string) ($territorio['legacy_tipologia_id'] ?? ($territorio['tipologia_id'] ?? ''))); $node->addChild('legacy_tipologia_id', (string) ($territorio['legacy_tipologia_id'] ?? ($territorio['tipologia_id'] ?? '')));
@@ -761,44 +901,49 @@ class XmlExchange extends Component
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? '')); $this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1))); $node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0))); $node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
$this->addXmlText($node, 'pdf_path', (string) ($territorio['pdf_path'] ?? ''));
$this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? ''));
$node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? ''));
$this->addTimestampNodes($node, $territorio);
} }
$anniNode = $xml->addChild('anni_teocratici'); $anniNode = $xml->addChild('anni_teocratici');
foreach ($dataset['anni_teocratici'] as $anno) { foreach ($dataset['anni_teocratici'] as $anno) {
$node = $anniNode->addChild('anno'); $node = $anniNode->addChild('anno');
if (isset($anno['legacy_id'])) { $this->addLegacyIdAttribute($node, $anno);
$node->addAttribute('legacy_id', (string) $anno['legacy_id']);
}
$this->addXmlText($node, 'label', (string) ($anno['label'] ?? '')); $this->addXmlText($node, 'label', (string) ($anno['label'] ?? ''));
$node->addChild('start_date', (string) ($anno['start_date'] ?? '')); $node->addChild('start_date', (string) ($anno['start_date'] ?? ''));
$node->addChild('end_date', (string) ($anno['end_date'] ?? '')); $node->addChild('end_date', (string) ($anno['end_date'] ?? ''));
$this->addTimestampNodes($node, $anno);
} }
$campagneNode = $xml->addChild('campagne'); $campagneNode = $xml->addChild('campagne');
foreach ($dataset['campagne'] as $campagna) { foreach ($dataset['campagne'] as $campagna) {
$node = $campagneNode->addChild('campagna'); $node = $campagneNode->addChild('campagna');
if (isset($campagna['legacy_id'])) { $this->addLegacyIdAttribute($node, $campagna);
$node->addAttribute('legacy_id', (string) $campagna['legacy_id']);
}
$this->addXmlText($node, 'descrizione', (string) ($campagna['descrizione'] ?? '')); $this->addXmlText($node, 'descrizione', (string) ($campagna['descrizione'] ?? ''));
$node->addChild('start_date', (string) ($campagna['start_date'] ?? '')); $node->addChild('start_date', (string) ($campagna['start_date'] ?? ''));
$node->addChild('end_date', (string) ($campagna['end_date'] ?? '')); $node->addChild('end_date', (string) ($campagna['end_date'] ?? ''));
$this->addTimestampNodes($node, $campagna);
} }
$assegnazioniNode = $xml->addChild('assegnazioni'); $assegnazioniNode = $xml->addChild('assegnazioni');
foreach ($dataset['assegnazioni'] as $assegnazione) { foreach ($dataset['assegnazioni'] as $assegnazione) {
$node = $assegnazioniNode->addChild('assegnazione'); $node = $assegnazioniNode->addChild('assegnazione');
if (isset($assegnazione['legacy_id'])) { $this->addLegacyIdAttribute($node, $assegnazione);
$node->addAttribute('legacy_id', (string) $assegnazione['legacy_id']);
}
$node->addChild('legacy_territorio_id', (string) ($assegnazione['legacy_territorio_id'] ?? ($assegnazione['territorio_id'] ?? ''))); $node->addChild('legacy_territorio_id', (string) ($assegnazione['legacy_territorio_id'] ?? ($assegnazione['territorio_id'] ?? '')));
$node->addChild('legacy_proclamatore_id', (string) ($assegnazione['legacy_proclamatore_id'] ?? ($assegnazione['proclamatore_id'] ?? ''))); $node->addChild('legacy_proclamatore_id', (string) ($assegnazione['legacy_proclamatore_id'] ?? ($assegnazione['proclamatore_id'] ?? '')));
$node->addChild('legacy_anno_id', (string) ($assegnazione['legacy_anno_id'] ?? ($assegnazione['anno_teocratico_id'] ?? ''))); $node->addChild('legacy_anno_id', (string) ($assegnazione['legacy_anno_id'] ?? ($assegnazione['anno_teocratico_id'] ?? '')));
$node->addChild('legacy_campagna_id', (string) ($assegnazione['legacy_campagna_id'] ?? ($assegnazione['campaign_id'] ?? ''))); $node->addChild('legacy_campagna_id', (string) ($assegnazione['legacy_campagna_id'] ?? ($assegnazione['campaign_id'] ?? '')));
$node->addChild('legacy_created_by_id', (string) ($assegnazione['legacy_created_by_id'] ?? ($assegnazione['created_by'] ?? '')));
$node->addChild('legacy_returned_by_id', (string) ($assegnazione['legacy_returned_by_id'] ?? ($assegnazione['returned_by'] ?? '')));
$node->addChild('assigned_at', (string) ($assegnazione['assigned_at'] ?? '')); $node->addChild('assigned_at', (string) ($assegnazione['assigned_at'] ?? ''));
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? '')); $node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0))); $node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? '')); $this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
$this->addTimestampNodes($node, $assegnazione);
} }
return $xml->asXML() ?: ''; return $xml->asXML() ?: '';
@@ -812,4 +957,75 @@ class XmlExchange extends Component
return $child; return $child;
} }
private function addTimestampNodes(\SimpleXMLElement $node, array $row): void
{
$node->addChild('created_at', (string) ($row['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($row['updated_at'] ?? ''));
}
/**
* Write the legacy_id attribute used to remap foreign keys on import.
* The legacy-SQL conversion supplies an explicit 'legacy_id'; the
* current-app export supplies the real 'id' — either works as the stable
* key the referencing rows (territori, assegnazioni, …) point back to.
*/
private function addLegacyIdAttribute(\SimpleXMLElement $node, array $row): void
{
$legacyId = $row['legacy_id'] ?? $row['id'] ?? null;
if ($legacyId !== null && (string) $legacyId !== '') {
$node->addAttribute('legacy_id', (string) $legacyId);
}
}
/**
* Parse a full datetime (created_at/updated_at/deleted_at/email_verified_at)
* for storage, returning a `Y-m-d H:i:s` string or null. Unlike
* normalizeDateForDb this keeps the time component.
*/
private function normalizeDateTimeForDb(string $raw): ?string
{
$raw = trim($raw);
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return null;
}
try {
return Carbon::parse($raw)->toDateTimeString();
} catch (\Throwable $e) {
return null;
}
}
/**
* Restore created_at/updated_at (and deleted_at, when the model soft
* deletes) from the XML node after the row has been created. Written with
* the query builder so Eloquent does not stamp its own timestamps over the
* imported values. Only columns present in the XML are touched.
*/
private function applyRowMeta(string $table, int $id, \SimpleXMLElement $node, bool $withSoftDelete = false): void
{
$updates = [];
$createdAt = $this->normalizeDateTimeForDb((string) ($node->created_at ?? ''));
if ($createdAt !== null) {
$updates['created_at'] = $createdAt;
}
$updatedAt = $this->normalizeDateTimeForDb((string) ($node->updated_at ?? ''));
if ($updatedAt !== null) {
$updates['updated_at'] = $updatedAt;
}
if ($withSoftDelete) {
$deletedAt = $this->normalizeDateTimeForDb((string) ($node->deleted_at ?? ''));
if ($deletedAt !== null) {
$updates['deleted_at'] = $deletedAt;
}
}
if ($updates !== []) {
DB::table($table)->where('id', $id)->update($updates);
}
}
} }

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
return new class extends Migration
{
/**
* Guarantee that no "assistente" can modify or delete existing
* assignments.
*
* The only way to edit/delete an assegnazione is through the Registro's
* admin CRUD, which is gated on `settings.manage` (backend abort_if + the
* @can in registro.blade.php). By baseline the assistente role never has
* that permission, so an assistente can only view the registro, create new
* assignments (territori.assign) and return them (territori.return).
*
* RolesAndPermissionsSeeder grants additively (givePermissionTo, never
* syncPermissions) so it can re-run without clobbering UI-granted
* permissions — which means it also never *revokes*. If an administrator
* had ever granted settings.manage to the assistente role through the UI,
* that drift would survive every re-seed. This migration closes that gap
* for existing databases. It is idempotent: a no-op when the role already
* lacks the permission.
*/
public function up(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
$role = Role::where('name', 'assistente')->first();
$permission = Permission::where('name', 'settings.manage')->first();
if ($role && $permission && $role->hasPermissionTo($permission)) {
$role->revokePermissionTo($permission);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
/**
* Intentionally irreversible. Re-granting settings.manage to the
* assistente role would reintroduce the very ability this migration
* removes, so rolling back must not do it.
*/
public function down(): void
{
// no-op
}
};

View File

@@ -292,6 +292,7 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-3"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-3">
<h2 class="text-lg font-semibold text-gray-900">Log importazione</h2> <h2 class="text-lg font-semibold text-gray-900">Log importazione</h2>
<div class="text-sm text-gray-700" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;"> <div class="text-sm text-gray-700" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;">
<div>Utenti importati: <strong>{{ $importStats['utenti_importati'] ?? 0 }}</strong></div>
<div>Zone importate: <strong>{{ $importStats['zone_importate'] ?? 0 }}</strong></div> <div>Zone importate: <strong>{{ $importStats['zone_importate'] ?? 0 }}</strong></div>
<div>Tipologie importate: <strong>{{ $importStats['tipologie_importate'] ?? 0 }}</strong></div> <div>Tipologie importate: <strong>{{ $importStats['tipologie_importate'] ?? 0 }}</strong></div>
<div>Proclamatori importati: <strong>{{ $importStats['proclamatori_importati'] ?? 0 }}</strong></div> <div>Proclamatori importati: <strong>{{ $importStats['proclamatori_importati'] ?? 0 }}</strong></div>
@@ -341,7 +342,12 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">Export XML</h2> <h2 class="text-lg font-semibold text-gray-900">Export XML</h2>
<p class="text-xs text-gray-500">Esporta i dati correnti dell'app in XML.</p> <p class="text-xs text-gray-500">Esporta <strong>tutti</strong> i dati correnti dell'app in XML (backup completo, reimportabile).</p>
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
<strong>Dati sensibili.</strong> Il file include gli utenti con le password (hash) e i nomi dei proclamatori in chiaro.
Conservalo in un luogo sicuro e non condividerlo: chi lo ottiene può tentare di forzare le password offline.
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button

View File

@@ -26,6 +26,7 @@
<div class="meta">Generato il {{ $generatedAt }}</div> <div class="meta">Generato il {{ $generatedAt }}</div>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat"><div class="stat-label">Utenti importati</div><div class="stat-value">{{ $stats['utenti_importati'] ?? 0 }}</div></div>
<div class="stat"><div class="stat-label">Zone importate</div><div class="stat-value">{{ $stats['zone_importate'] ?? 0 }}</div></div> <div class="stat"><div class="stat-label">Zone importate</div><div class="stat-value">{{ $stats['zone_importate'] ?? 0 }}</div></div>
<div class="stat"><div class="stat-label">Tipologie importate</div><div class="stat-value">{{ $stats['tipologie_importate'] ?? 0 }}</div></div> <div class="stat"><div class="stat-label">Tipologie importate</div><div class="stat-value">{{ $stats['tipologie_importate'] ?? 0 }}</div></div>
<div class="stat"><div class="stat-label">Proclamatori importati</div><div class="stat-value">{{ $stats['proclamatori_importati'] ?? 0 }}</div></div> <div class="stat"><div class="stat-label">Proclamatori importati</div><div class="stat-value">{{ $stats['proclamatori_importati'] ?? 0 }}</div></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB