diff --git a/.gitignore b/.gitignore index e99ba81..62abd1c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /storage/app/livewire-tmp/*.* /storage/logs/ /storage/framework/ +/storage/app/public/territori-thumbnails/*.png /bootstrap/cache/ /public/hot /public/storage diff --git a/HANDOFF.md b/HANDOFF.md index 12cd74c..0bf69e0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 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 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 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` | | 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` | +| 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 | --- @@ -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 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 diff --git a/app/Livewire/Settings/XmlExchange.php b/app/Livewire/Settings/XmlExchange.php index 9c2d3a2..08281ad 100644 --- a/app/Livewire/Settings/XmlExchange.php +++ b/app/Livewire/Settings/XmlExchange.php @@ -181,6 +181,7 @@ class XmlExchange extends Component } $stats = [ + 'utenti_importati' => 0, 'zone_importate' => 0, 'tipologie_importate' => 0, 'proclamatori_importati' => 0, @@ -212,22 +213,96 @@ class XmlExchange extends Component if ($settingsNode) { Setting::instance()->update([ '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_prioritari' => (int) ($settingsNode->giorni_giacenza_prioritari ?? 180), 'giorni_per_smarrito' => (int) ($settingsNode->giorni_per_smarrito ?? 120), '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), + 'pdf_viewer_show_download' => ((int) ($settingsNode->pdf_viewer_show_download ?? 0)) === 1, '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, ]); } + // 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 = []; foreach (($xml->zones->zone ?? []) as $zoneNode) { $z = Zona::create([ 'nome' => (string) $zoneNode->nome, 'attivo' => ((int) ($zoneNode->attivo ?? 1)) === 1, ]); + $this->applyRowMeta('zone', $z->id, $zoneNode); $stats['zone_importate']++; $legacyId = (string) ($zoneNode['legacy_id'] ?? ''); if ($legacyId !== '') { @@ -241,6 +316,7 @@ class XmlExchange extends Component 'nome' => (string) $tipologiaNode->nome, 'attivo' => ((int) ($tipologiaNode->attivo ?? 1)) === 1, ]); + $this->applyRowMeta('tipologie', $t->id, $tipologiaNode); $stats['tipologie_importate']++; $legacyId = (string) ($tipologiaNode['legacy_id'] ?? ''); if ($legacyId !== '') { @@ -255,6 +331,7 @@ class XmlExchange extends Component 'cognome' => (string) $proclamatoreNode->cognome, 'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1, ]); + $this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true); $stats['proclamatori_importati']++; $legacyId = (string) ($proclamatoreNode['legacy_id'] ?? ''); if ($legacyId !== '') { @@ -270,6 +347,7 @@ class XmlExchange extends Component 'start_date' => (string) $annoNode->start_date, 'end_date' => (string) $annoNode->end_date, ]); + $this->applyRowMeta('anni_teocratici', $anno->id, $annoNode); $stats['anni_importati']++; $legacyId = (string) ($annoNode['legacy_id'] ?? ''); if ($legacyId !== '') { @@ -284,6 +362,7 @@ class XmlExchange extends Component 'start_date' => (string) $campagnaNode->start_date, 'end_date' => (string) $campagnaNode->end_date, ]); + $this->applyRowMeta('campagne', $campagna->id, $campagnaNode); $stats['campagne_importate']++; $legacyId = (string) ($campagnaNode['legacy_id'] ?? ''); if ($legacyId !== '') { @@ -310,6 +389,9 @@ class XmlExchange extends Component continue; } + $pdfPath = (string) ($territorioNode->pdf_path ?? ''); + $thumbnailPath = (string) ($territorioNode->thumbnail_path ?? ''); + $territorio = Territorio::create([ 'numero' => $numero, 'zona_id' => $zoneMap[$legacyZonaId], @@ -318,7 +400,10 @@ class XmlExchange extends Component 'note' => (string) ($territorioNode->note ?? ''), 'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 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; $stats['territori_importati']++; @@ -350,7 +435,24 @@ class XmlExchange extends Component $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], 'proclamatore_id' => $proclamatoreMap[$legacyProclamatore], 'anno_teocratico_id' => $annoMap[$legacyAnno], @@ -358,10 +460,13 @@ class XmlExchange extends Component 'returned_at' => $returnedAt, 'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1, '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 ?? ''), - 'created_by' => $actorId, - 'returned_by' => $returnedAt !== null ? $actorId : null, + 'created_by' => $createdBy, + 'returned_by' => $returnedBy, ]); + $this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode); $stats['assegnazioni_importate']++; } }); @@ -379,20 +484,39 @@ class XmlExchange extends Component return [ 'settings' => [ '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_prioritari' => (int) ($settings->giorni_giacenza_prioritari ?? 180), 'giorni_per_smarrito' => (int) ($settings->giorni_per_smarrito ?? 120), 'home_limit_list' => (int) ($settings->home_limit_list ?? 10), '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), + 'setup_completed' => (int) ((bool) ($settings->setup_completed ?? true)), ], - 'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(), - 'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(), - 'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo'])->toArray(), - 'territori' => Territorio::query()->withTrashed()->orderBy('id')->get(['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario'])->toArray(), - 'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date'])->toArray(), - 'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date'])->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'])->toArray(), + // Full user rows, credentials included. getAuthPassword() bypasses + // the $hidden guard to read the raw bcrypt hash; roles come from the + // Spatie relation. This is sensitive data — see the note in the + // export UI / HANDOFF. + 'users' => User::query()->with('roles')->orderBy('id')->get()->map(fn ($u) => [ + 'legacy_id' => (string) $u->id, + '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); } + // 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'); foreach ($dataset['zones'] as $zone) { $node = $zonesNode->addChild('zone'); - if (isset($zone['legacy_id'])) { - $node->addAttribute('legacy_id', (string) $zone['legacy_id']); - } + $this->addLegacyIdAttribute($node, $zone); $this->addXmlText($node, 'nome', (string) ($zone['nome'] ?? '')); $node->addChild('attivo', (string) ((int) ($zone['attivo'] ?? 1))); + $this->addTimestampNodes($node, $zone); } $tipologieNode = $xml->addChild('tipologie'); foreach ($dataset['tipologie'] as $tipologia) { $node = $tipologieNode->addChild('tipologia'); - if (isset($tipologia['legacy_id'])) { - $node->addAttribute('legacy_id', (string) $tipologia['legacy_id']); - } + $this->addLegacyIdAttribute($node, $tipologia); $this->addXmlText($node, 'nome', (string) ($tipologia['nome'] ?? '')); $node->addChild('attivo', (string) ((int) ($tipologia['attivo'] ?? 1))); + $this->addTimestampNodes($node, $tipologia); } $proclamatoriNode = $xml->addChild('proclamatori'); foreach ($dataset['proclamatori'] as $proclamatore) { $node = $proclamatoriNode->addChild('proclamatore'); - if (isset($proclamatore['legacy_id'])) { - $node->addAttribute('legacy_id', (string) $proclamatore['legacy_id']); - } + $this->addLegacyIdAttribute($node, $proclamatore); $this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? '')); $this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? '')); $node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1))); + $node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? '')); + $this->addTimestampNodes($node, $proclamatore); } $territoriNode = $xml->addChild('territori'); foreach ($dataset['territori'] as $territorio) { $node = $territoriNode->addChild('territorio'); - if (isset($territorio['legacy_id'])) { - $node->addAttribute('legacy_id', (string) $territorio['legacy_id']); - } + $this->addLegacyIdAttribute($node, $territorio); $this->addXmlText($node, 'numero', (string) ($territorio['numero'] ?? '')); $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'] ?? ''))); @@ -761,44 +901,49 @@ class XmlExchange extends Component $this->addXmlText($node, 'note', (string) ($territorio['note'] ?? '')); $node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1))); $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'); foreach ($dataset['anni_teocratici'] as $anno) { $node = $anniNode->addChild('anno'); - if (isset($anno['legacy_id'])) { - $node->addAttribute('legacy_id', (string) $anno['legacy_id']); - } + $this->addLegacyIdAttribute($node, $anno); $this->addXmlText($node, 'label', (string) ($anno['label'] ?? '')); $node->addChild('start_date', (string) ($anno['start_date'] ?? '')); $node->addChild('end_date', (string) ($anno['end_date'] ?? '')); + $this->addTimestampNodes($node, $anno); } $campagneNode = $xml->addChild('campagne'); foreach ($dataset['campagne'] as $campagna) { $node = $campagneNode->addChild('campagna'); - if (isset($campagna['legacy_id'])) { - $node->addAttribute('legacy_id', (string) $campagna['legacy_id']); - } + $this->addLegacyIdAttribute($node, $campagna); $this->addXmlText($node, 'descrizione', (string) ($campagna['descrizione'] ?? '')); $node->addChild('start_date', (string) ($campagna['start_date'] ?? '')); $node->addChild('end_date', (string) ($campagna['end_date'] ?? '')); + $this->addTimestampNodes($node, $campagna); } $assegnazioniNode = $xml->addChild('assegnazioni'); foreach ($dataset['assegnazioni'] as $assegnazione) { $node = $assegnazioniNode->addChild('assegnazione'); - if (isset($assegnazione['legacy_id'])) { - $node->addAttribute('legacy_id', (string) $assegnazione['legacy_id']); - } + $this->addLegacyIdAttribute($node, $assegnazione); $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_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_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('returned_at', (string) ($assegnazione['returned_at'] ?? '')); $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->addTimestampNodes($node, $assegnazione); } return $xml->asXML() ?: ''; @@ -812,4 +957,75 @@ class XmlExchange extends Component 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); + } + } } diff --git a/database/migrations/2026_07_25_000001_revoke_settings_manage_from_assistente_role.php b/database/migrations/2026_07_25_000001_revoke_settings_manage_from_assistente_role.php new file mode 100644 index 0000000..6b68760 --- /dev/null +++ b/database/migrations/2026_07_25_000001_revoke_settings_manage_from_assistente_role.php @@ -0,0 +1,51 @@ +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 + } +}; diff --git a/resources/views/livewire/settings/xml-exchange.blade.php b/resources/views/livewire/settings/xml-exchange.blade.php index 9f67c37..91f99e2 100644 --- a/resources/views/livewire/settings/xml-exchange.blade.php +++ b/resources/views/livewire/settings/xml-exchange.blade.php @@ -292,6 +292,7 @@
Esporta i dati correnti dell'app in XML.
+Esporta tutti i dati correnti dell'app in XML (backup completo, reimportabile).
+ +