Con la fase F1 Storm Support ha cambiato modo di scrivere il codice di dominio: contesti separati, comandi, eventi, outbox, uno strato che isola WordPress. Questa pagina spiega ogni idea partendo da come la scriveresti in Rails, con esempi presi dall'agenda appuntamenti.
Presuppone che tu conosca Ruby, Rails e le sue convenzioni (modelli Active Record, callback, validazioni, controller, job). Non serve conoscere DDD o architetture a eventi. Il codice citato viene da epic/migrazione-F1 al 12 novembre 2026; gli esempi marcati Rails piano sono ipotetici e mostrano come lo avremmo scritto senza la migrazione.
Ogni riga è una cosa che in Rails sapresti già fare, con la forma che ha preso nella migrazione. Le sezioni seguenti spiegano il perché di ciascuna.
| In Rails piano | Nella migrazione | Dove guardare |
|---|---|---|
Tutto in app/models, ogni classe può usare ogni altra | Un pacchetto per contesto, con una facciata pubblica; packwerk blocca gli accessi non dichiarati | packs/*/package.yml |
Appuntamento.create nel controller, effetti nelle callback del modello | Un comando con un nome di dominio: FissaAppuntamento | packs/pianificazione/app/public/…/comandi |
validate ed errors.add | Per le regole di business: raise Dominio::RegolaViolata, :codice | packs/kernel/…/regola_violata.rb |
current_user, che esiste solo nel controller | Current.attore, che arriva anche nei job, negli audit e negli eventi | packs/kernel/app/public/attore.rb |
after_commit { SyncJob.perform_later } | Eventi.pubblica('pia.appuntamento.fissato', …) con outbox | packs/kernel/app/public/eventi.rb |
| Un job che si dà per eseguito una volta sola | Subscriber e job che reggono la doppia esecuzione | Eventi::Idempotente |
| Chiamate all'API di WP sparse nel codice | Tutto ciò che riguarda WP sta in un pacchetto che nessuno importa | packs/legacy_wp |
if current_user.admin? | richiedi_permesso!(:agenda_gestisci) su una matrice ruolo → azioni | Accessi::Permessi |
| Rilascio e via | Il comportamento nuovo è dietro un flag e si accende in bassa stagione | Feature.active?(:agenda_comandi_locali) |
La Rails Way dà il meglio quando un'applicazione possiede il proprio database e ogni dato ha una sola casa. Il modello è "grasso", le callback fanno il lavoro di contorno e il controller tiene insieme tutto. Storm Support non è in questa situazione.
WordPress è ancora il master di quasi tutto. Per un anno abbondante Support e WP terranno gli stessi dati, e per ogni dominio cambierà chi comanda. Un after_save che scrive su WP non distingue i due casi.
Un appuntamento cambia per mano di un operatore, di un job notturno che annulla le conferme scadute, del webhook di WP, di un import, del cliente da un form pubblico e, con F5b, dell'app dei periti. Il current_user del controller copre solo il primo caso.
L'analisi ha individuato 14 contesti: pratiche, pianificazione, valutazione, logistica, rete dei tecnici e altri. In una sola cartella app/models finirebbero per conoscersi tutti a vicenda.
Alla fine WordPress si spegne. Se le chiamate a WP sono dentro modelli e callback, spegnerlo vuol dire riscrivere metà dell'applicazione. Se sono in un posto solo, basta cancellare quel posto.
Domain-Driven Design è un insieme di pratiche per organizzare il codice attorno al business invece che attorno alle tabelle. Ne usiamo cinque idee.
Non tutte le aree valgono lo stesso. Le core sono quelle che distinguono l'azienda e meritano il modello più curato: Pratiche, Pianificazione, Valutazione. Le supporting servono al core ma non lo distinguono: Logistica, Rete dei tecnici, Comunicazioni e altre. Le generic sono uguali in ogni azienda e vanno fatte nel modo più semplice: Identità e accessi, Reporting. In Rails piano tutto riceve la stessa cura; qui la cura va dove rende.
Un bounded context è un confine dentro cui ogni parola ha un solo significato. "Appuntamento" è l'esempio più chiaro.
Una pratica, un hub, una tipologia (perizia, lavorazione), una data, la capacità che consuma. Si fissa, si sposta, si annulla.
Pickup, delivery, bisarca, sostitutiva. Qui conta dove sta l'auto e chi la porta, non lo slot in agenda.
Un post di tipo appuntamento con campi come data_1_appuntamento e hub_1_appuntamento. È un formato di trasporto, non un concetto di business.
Un unico modello Appointment che serve tutti e tre i significati accumula colonne, callback e condizioni. Con i contesti ogni significato ha il suo codice, e i contesti si parlano attraverso facciate ed eventi.
Le classi, i metodi e gli eventi usano le parole del business, in italiano, così come le dicono gli operatori. L'operatore "fissa un appuntamento", quindi il codice ha FissaAppuntamento e l'evento pia.appuntamento.fissato, non AppointmentsController#create e appointment_created. Quando una parola del business cambia, cambia anche nel codice.
Un aggregato è un gruppo di oggetti che cambiano sempre insieme, dentro una transazione, attraverso un punto d'ingresso. L'appuntamento è un aggregato: lo cambiano solo i comandi di Pianificazione, e ogni evento dice di quale aggregato parla (aggregate_type, aggregate_id). In Rails piano chiunque può fare appuntamento.update_column(:data, …); qui lo si considera un bug.
Un value object conta per il suo valore, non per un id, e non cambia dopo essere stato creato. Attore è un value object fatto con Data.define di Ruby: due attori con lo stesso tipo e id sono lo stesso attore, e nessuno può modificarlo mentre viaggia tra richiesta, job ed evento.
Non abbiamo spezzato Support in microservizi. Ogni contesto è un pacchetto dentro la stessa app Rails (gem packs-rails), e packwerk controlla in CI che un pacchetto usi solo quelli che dichiara. Ogni pacchetto ha una facciata in app/public/, l'unica parte visibile agli altri. Il resto (app/models, app/services) è privato.
flowchart LR LW["legacy_wp
sync verso WordPress"] --> PIA["pianificazione
agenda, comandi"] LW --> ACC LW --> RET LW --> LOG PIA --> ACC["accessi
ruoli e permessi"] PIA --> RET["rete
tecnici, hub"] PIA --> LOG["logistica
flotta, trasporti"] ACC --> K["kernel
Attore, Eventi, Dominio"] RET --> K LOG --> K PIA --> K LW --> K
Tutti dipendono anche dalla radice (il codice storico in app/). Nessuno dipende da legacy_wp: quando WordPress si spegne, si cancella il pacchetto.
# app/models/trasporto.rb
class Trasporto < ApplicationRecord
def pianifica!
# Logistica entra nei dettagli interni di Pianificazione
# e di WordPress: nessuno lo impedisce.
slot = Appuntamento.where(hub: hub).
disponibili_per(:pickup).first
slot.update_column(:pickup_necessario, true)
Clients::Wp.new.update_post('appuntamento',
slot.wp_post_original_id, acf: { pickup_necessario: true })
end
end
# Contesto pianificazione: vedi doc/dev/contesti_e_eventi.md
enforce_dependencies: true # solo i pacchetti elencati sotto
enforce_privacy: true # degli altri, solo app/public
dependencies:
- .
- packs/kernel
- packs/accessi
- packs/rete
- packs/logistica
# legacy_wp non c'è: Pianificazione non sa che WP esiste
Appuntamento, bin/packwerk check fallirebbe in CI.pianificazione_, legacy_wp_, kernel_.packwerk legge i riferimenti a costanti scritti nel codice. Non vede i nomi di classe in stringa (constantize, class_name: 'X', i job in recurring.yml), i nomi degli eventi e gli spec. Il registro di LegacyWp usa proprio nomi in stringa: per questo ha un test che li risolve tutti, e quel test va tenuto verde.
Un comando è un oggetto che rappresenta un'intenzione del business ("fissa questo appuntamento") e la esegue tutta: controlla le regole, scrive nel database, registra chi l'ha fatto e pubblica cosa è successo. È simile ai service object che Rails conosce già, con tre vincoli in più: il nome viene dal linguaggio di dominio, il comando è l'unica strada per quella modifica, e tutto avviene in una transazione.
class AppuntamentiController < ApplicationController
def create
@appuntamento = @pratica.appuntamenti.new(appuntamento_params)
if @appuntamento.save
redirect_to @pratica, notice: 'Appuntamento creato'
else
render :new, status: :unprocessable_entity
end
end
end
class Appuntamento < ApplicationRecord
validate :pratica_sincronizzata
before_save :calcola_stima_data_fine
after_create_commit :crea_post_su_wp
after_create_commit :invia_conferma_al_cliente
after_update_commit :aggiorna_post_su_wp
# … e chi crea un appuntamento da un import
# o da un test fa partire tutto questo
end
module Pianificazione
module Comandi
class FissaAppuntamento < Base
def initialize(pratica:, hub:, tipologia:, data:, attore:,
durata_giorni: nil, hub_ingresso: hub,
data_ingresso: data)
# … assegna i parametri, calcola la durata di default
end
def call
verifica_sincronizzazione!
Appuntamento.transaction do
con_autore(attore) do # audit con l'attore
appuntamento = Appuntamento.create!(
pratica:, hub:, hub_ingresso:, tipologia:, data:,
data_ingresso:, stima_data_fine: data + durata_giorni.days
)
::Eventi.pubblica(
'pia.appuntamento.fissato', aggregato: appuntamento,
attore:, pratica_id: pratica.id, hub_id: hub.id,
hub_ingresso_id: hub_ingresso.id, tipologia:, data:
)
appuntamento
end
end
end
end
end
end
update_column o un insert_all salta le callback senza avvisare.I comandi dell'agenda in F1 sono tre: FissaAppuntamento, ModificaAppuntamento (accetta solo una lista chiusa di attributi e pubblica i campi davvero cambiati) e AnnullaAppuntamento (annullamento logico, con un motivo tra conferma_scaduta, rifiutato_dal_cliente, chiusura_telefonica, manuale). Si chiamano tutti con Comando.call(**parametri).
Le validazioni di Active Record restano per la forma dei dati (presenza, formato). Le regole di business ("non si fissa un appuntamento su una pratica che WP non conosce ancora") diventano Dominio::RegolaViolata: un'eccezione con un codice stabile e un messaggio tradotto per l'operatore.
class Appuntamento < ApplicationRecord
validate :pratica_sincronizzata
private
def pratica_sincronizzata
return if pratica&.wp_pratica_id
errors.add(:pratica, 'non ancora sincronizzata da WordPress')
end
end
# Chi la intercetta? Il form, leggendo errors[:pratica].
# Un job o un'API ricevono solo save == false.
# nel comando
def verifica_sincronizzazione!
raise ::Dominio::RegolaViolata, :pratica_non_sincronizzata if pratica.wp_pratica_id.nil?
raise ::Dominio::RegolaViolata, :hub_non_sincronizzato if hub.wp_hub_id.nil?
end
# config/locales/dominio.it.yml
# it.dominio.regole.pratica_non_sincronizzata: "La pratica non è …"
# nel controller
def create
fissa_appuntamento(hub:, tipologia:, data: data_e_ora, durata_giorni:)
redirect_to pratica_path(@pratica), notice: t('…creato')
rescue Dominio::RegolaViolata => e
redirect_back_to_form(alert: e.messaggio)
end
Il codice (e.codice == :pratica_non_sincronizzata) è pensato per chi non è un form: un job, i test e domani l'API v1 per l'app dei periti, che potrà restituirlo così com'è. L'opzione forzabile: true segna le regole che un operatore autorizzato può scavalcare.
In Rails piano "chi" è current_user, che esiste nel controller e sparisce appena il lavoro passa a un job. Qui ogni scrittura ha un attore, che può essere di cinque tipi: user (operatore), job, legacy_wp (un utente WP, dal webhook), import, pubblico (il cliente da un form). L'attore sta in Current.attore e viaggia con il lavoro.
flowchart LR S["Sessione
Authentication"] -->|"Attore.utente(user)"| C["Current.attore"] C --> CMD["FissaAppuntamento
attore: Current.attore"] CMD --> AUD["audited
autore della modifica"] CMD --> EV["Evento
actor: user 42
origin: back_office"] EV --> J["ProiettaJob
attore in job_data"] J --> C2["Current.attore
dentro perform"] C2 --> AUD2["audit e eventi
del job"]
class AppuntamentiController < ApplicationController
def destroy
AnnullaJob.perform_later(@appuntamento.id)
end
end
class AnnullaJob < ApplicationJob
def perform(id)
# current_user qui non esiste: l'audit registra nil
# e nessuno saprà chi ha annullato.
Appuntamento.find(id).update!(stato: 'annullato')
end
end
class Attore < Data.define(:tipo, :id, :user, :etichetta)
TIPI = %w[user job legacy_wp import pubblico].freeze
def self.utente(user) = new(tipo: 'user', id: user.id, user:)
def self.job(nome) = new(tipo: 'job', id: nome)
def self.legacy_wp(wp_user_id) = new(tipo: 'legacy_wp', id: wp_user_id,
user: User.find_by(legacy_wp_id: wp_user_id))
# …
end
# Il job eredita l'attore di chi lo accoda (viaggia in job_data):
AnnullaJob.perform_later(appuntamento.id)
# Oppure lo riceve esplicito:
ImportaJob.con_attore(Attore.import('tecnici_2026')).perform_later
# Senza nessuno dei due, l'attore è job:<NomeJob>.
L'attore finisce in tre posti: negli audit (audit_user), nella busta di ogni evento (actor e origin) e nel filtro anti-eco, che riconosce le scritture fatte da Support su WP perché portano l'utente di sync.
Un evento di dominio è un fatto avvenuto nel business, scritto al passato: "l'appuntamento è stato fissato". Chi lo pubblica non sa chi lo ascolta. Chi lo ascolta (un subscriber) reagisce per conto suo. In Rails piano lo stesso lavoro lo fanno le callback del modello, che però devono conoscere tutti i destinatari.
Il nome segue la convenzione <contesto>.<aggregato>.<fatto_al_passato>, con contesti pia, ret, log, acc, legacy_wp. Un nome fuori convenzione fa sollevare Eventi.pubblica. Sotto c'è Rails.event, il reporter di eventi strutturati di Rails 8.1.
sequenceDiagram actor Op as Operatore participant Ctl as AppuntamentiController participant Cmd as FissaAppuntamento participant DB as Database participant Bus as Rails.event participant Sub as RichiestaProiezioneSubscriber participant Tr as TraceSubscriber Op->>Ctl: POST appuntamento Ctl->>Cmd: call(attore: Current.attore) Cmd->>DB: BEGIN Cmd->>DB: INSERT appuntamenti Cmd->>DB: INSERT kernel_eventi pia.appuntamento.fissato Cmd->>DB: COMMIT Ctl-->>Op: redirect con "Appuntamento creato" DB-->>Bus: dopo il commit, Consegna Bus->>Sub: emit(event) Bus->>Tr: emit(event) Sub->>Sub: chiede la proiezione su WP
class Appuntamento < ApplicationRecord
after_create_commit do
WpSyncJob.perform_later(id)
ConfermaClienteJob.perform_later(id)
HubCalendarioBroadcast.call(hub)
Analytics.track('appuntamento_creato', id)
end
end
# Aggiungere una reazione = modificare il modello.
# Toglierne una (WP spento) = modificare il modello.
{
"event_id": "5b0d…-…-e41f",
"occurred_at": "2026-11-12T10:41:07.123456+01:00",
"actor": { "type": "user", "id": 42 },
"origin": "back_office",
"aggregate_type": "Appuntamento",
"aggregate_id": "a7c2…",
"version": 1,
"pratica_id": "…", "hub_id": "…", "hub_ingresso_id": "…",
"tipologia": "perizia",
"data": "2026-11-18T09:00:00+01:00"
}
config/initializers/event_subscribers.rb. Quando WP si spegne si toglie una riga, il comando non cambia. I dati passano da jsonb: chiavi simbolo e valori JSON, quindi niente oggetti Active Record nel payload.Support aveva già eventi prima di F1, con prefisso ss. (per esempio ss.wp.appuntamento.created). Sono eventi tecnici, che dicono "WordPress ha cambiato un post". Gli eventi pia.* sono di dominio e dicono cosa è successo nel business.
Scrivere nel database e avvisare qualcun altro sono due operazioni distinte. Rails piano offre due posti per l'avviso, e falliscono entrambi in un caso preciso.
Se il job viene accodato dentro la transazione, può partire prima del commit e non trovare l'appuntamento. Se poi la transazione fa rollback, il job lavora su un appuntamento che non esiste.
Con after_commit { perform_later }, se il processo muore tra il commit e l'accodamento (deploy, OOM, riavvio) l'appuntamento c'è ma WP non lo saprà mai.
L'evento si scrive come riga di kernel_eventi nella stessa transazione dell'appuntamento. O esistono entrambi o nessuno dei due. La consegna avviene dopo, e se fallisce si ritenta.
flowchart TB A["Comando: INSERT appuntamento
+ INSERT kernel_eventi
stessa transazione"] --> B{"COMMIT"} B -->|"rollback"| X["nessun evento:
niente da consegnare"] B -->|"ok"| C["after_all_transactions_commit
Eventi::Consegna"] C -->|"riuscita"| D["consegnato_il valorizzato"] C -->|"errore o processo morto"| E["resta da consegnare
tentativi +1, ultimo_errore"] E --> F["Eventi::RiconsegnaJob
ogni 5 minuti, eventi più vecchi di 10"] F --> C
def self.pubblica(nome, aggregato:, attore: Current.attore, **dati)
raise ArgumentError, "nome evento fuori convenzione: #{nome.inspect}" unless NOME.match?(nome.to_s)
raise ArgumentError, "serve un attore per pubblicare #{nome}" if attore.nil?
busta = { event_id:, occurred_at:, actor: attore.to_h, origin: attore.origin,
aggregate_type: aggregato.class.name, aggregate_id: aggregato.id, version: 1 }
evento = Evento.create!(event_id:, nome: nome.to_s, busta:, dati:) # nella transazione del comando
ActiveRecord.after_all_transactions_commit do
Consegna.call(evento.id) # blocca la riga: una consegna sola anche se concorrente
rescue StandardError => e
Rails.error.report(e, handled: true) # il comando è salvo, ci pensa RiconsegnaJob
end
event_id
end
L'outbox garantisce che l'evento arrivi, non che arrivi una volta sola. Un processo che muore dopo la consegna ma prima di scrivere consegnato_il fa riconsegnare lo stesso evento. Per questo i subscriber devono essere idempotenti. Inoltre Rails.event.notify non solleva per gli errori dei subscriber: l'outbox copre la morte del processo, non i bug di chi ascolta.
Un'operazione è idempotente se ripeterla non cambia il risultato. In Rails piano si dà quasi sempre per scontato che un job giri una volta. Qui no: l'outbox riconsegna, i job ritentano, WP risponde 500 dopo aver salvato. Ogni livello ha la sua difesa.
| Livello | Cosa può ripetersi | Difesa in F1 |
|---|---|---|
| Subscriber | Lo stesso evento consegnato due volte | Eventi::Idempotente#una_volta: una riga in kernel_eventi_elaborati con indice unico (event_id, subscriber), scritta nella stessa transazione del lavoro |
| Richiesta di proiezione | Lo stesso pia.appuntamento.* due volte | Proiezione.richiedi! confronta ultimo_event_id e non accoda un secondo job |
| Comando | Lo sweep notturno ripassa su un appuntamento già annullato | AnnullaAppuntamento: next appuntamento if appuntamento.annullato? |
| Chiamata a WP | create_post va in timeout dopo l'insert, il job ritenta | Ogni post nasce con slug ss-<uuid>; prima di creare si cerca per slug e, se esiste, lo si riprende |
# Il post esiste già su WP: un fallimento del Pull non deve
# impedire di restituirne l'id, altrimenti il chiamante (che
# salva l'id per non ricreare al retry) non lo saprebbe mai e
# ogni retry creerebbe un altro appuntamento su WP.
def pull(wp_post_id)
WpPost::Pull.call(kind: 'appuntamento', wp_post_id: wp_post_id)
rescue StandardError => e
Rails.error.report(e, handled: true, context: { wp_post_id: })
end
# Un 500 dopo l'insert, un timeout o un retry non devono
# creare un secondo post: lo slug ss-<uuid> lo ritrova.
def crea_o_ritrova
slug = StatoProiezione.slug(@record)
esistente = @client.get_posts(@voce.kind, slug:, status: 'any').first
return ritrovato(esistente['id']) if esistente
# … altrimenti create_post
end
# Esempio (non esiste ancora): un subscriber nuovo
class AvvisaHubSubscriber
include Eventi::Idempotente
def emit(event)
una_volta(event) do
AvvisoHub.create!(hub_id: event[:payload][:hub_id])
end
end
end
Il commento a sinistra mostra il problema già noto: prima di F1 l'idempotenza era affidata alla prudenza del singolo service. Con F1 è una regola della piattaforma.
Due pattern lavorano insieme. Lo strangler fig (il fico strangolatore) sostituisce un sistema vecchio un pezzo alla volta, finché del vecchio non resta niente. L'anti-corruption layer è uno strato di traduzione che impedisce al modello del sistema vecchio (post, campi ACF, id WP) di entrare nel modello nuovo. In Support entrambi stanno in packs/legacy_wp.
Per l'agenda, F1 cambia la direzione del dato. Prima Support chiedeva a WP di creare l'appuntamento e aspettava che tornasse indietro. Dopo, Support lo crea in casa e lo proietta su WP, che continua a leggerlo finché serve.
sequenceDiagram participant S as Support participant W as WordPress S->>W: REST create_post W-->>S: id del post S->>W: pull del post S->>S: evento ss.wp.appuntamento.created S->>S: AppuntamentoHandler crea l'Appuntamento locale Note over S: il record locale è una copia,
nasce dopo e in modo asincrono
sequenceDiagram participant S as Support participant L as LegacyWp participant W as WordPress S->>S: FissaAppuntamento: record + evento S->>L: pia.appuntamento.fissato L->>L: Proiezione in_attesa, ProiettaJob L->>W: REST create_post con slug ss-uuid W-->>L: webhook del post appena scritto L->>L: FiltroEco: è un'eco, scartato Note over S: il record locale è l'originale,
WP riceve una copia
Una voce per tipo di record: famiglia di eventi (pia.appuntamento) → classe (Appuntamento) → adattatore → kind WP → colonna dell'id WP. I contesti non conoscono LegacyWp: il collegamento è l'evento più questa voce.
Adattatori::Appuntamento trasforma un appuntamento nei campi ACF di WP (data_1_appuntamento, hub_1_appuntamento …), con date in ora di Roma. Dichiara quali campi Support possiede.
Una riga per record in legacy_wp_proiezioni: stato, ultimo payload riuscito, tentativi, ultimo errore. Le fallite si vedono nel cruscotto admin.
Coda wp_write, uno alla volta per record (limits_concurrency), 5 tentativi con attesa crescente. Dopo l'ultimo la proiezione è fallita.
Ogni scrittura su WP genera un webhook di ritorno. Si scarta solo se l'autore è l'utente di sync, il kind non è di proprietà di WP e il record ha una riga di proiezione. Tutte e tre le condizioni.
stateDiagram-v2 [*] --> in_attesa: richiedi! in_attesa --> riuscita: ProiettaJob ok in_attesa --> fallita: 5 tentativi falliti riuscita --> in_attesa: nuovo evento fallita --> in_attesa: nuovo evento o riaccodo
flowchart TB
W["webhook da WP"] --> A{"autore = utente di sync?"}
A -->|"no"| P["si elabora come oggi"]
A -->|"sì"| B{"kind di cui WP è master?"}
B -->|"sì"| P
B -->|"no"| C{"record locale con
riga di proiezione?"}
C -->|"no"| P
C -->|"sì"| E["eco: si scarta"]
Questa è la regola che un Rails piano rompe più facilmente. Support non possiede tutti i campi del post WP: in F1 per esempio il tecnico, lo slot 2 e le movimentazioni restano scritti da WP. Se l'adattatore mandasse tecnico: nil, cancellerebbe il tecnico assegnato su WP.
class Appuntamento < ApplicationRecord
after_update_commit do
Clients::Wp.new.update_post('appuntamento', wp_post_original_id,
acf: attributes.transform_keys { |k| WP_MAP[k] })
# tecnico, slot 2, movimentazioni: nil
# → svuotati su WP a ogni modifica
end
end
# Chiave ASSENTE da acf(record) = "Support non la conosce,
# non toccarla". Chiave con nil = "va svuotata".
def payload_da_inviare(record, id_wp:, slug:, base:)
desiderato = acf(record).slice(*campi_posseduti)
# si inviano solo i campi cambiati rispetto all'ultima
# proiezione riuscita (base), più gli obbligatori del kind
cambiati = desiderato.reject { |c, v| base.key?(c) && base[c] == v }
# …
end
Ogni dominio passa a Support in quattro passi. L'agenda appuntamenti è il primo a percorrerli.
Il dominio esiste in Support con comandi ed eventi. Per l'agenda: i tre comandi di F1-04.
I dati di WP arrivano e restano allineati in Support. Per l'agenda la sync da WP esisteva già.
Support diventa master e WP riceve la proiezione. Si accende il flag, in bassa stagione (novembre–marzo).
Il percorso vecchio (Actions::OnWp::*) si cancella, poi le voci del registro, poi il pacchetto.
L'inversione non è un rilascio unico: il codice vecchio e quello nuovo convivono, e un flag sceglie quale usare. Spegnere il flag riporta tutto com'era senza un deploy.
def fissa_appuntamento(hub:, tipologia:, data:, durata_giorni:)
if Feature.active?(:agenda_comandi_locali)
# Support master: l'appuntamento nasce qui e si proietta su WP
Pianificazione::Comandi::FissaAppuntamento.call(pratica: @pratica, hub:, tipologia:, data:,
durata_giorni:, attore: Current.attore)
else
# WP master: si scrive su WP e si aspetta il ritorno via webhook
Actions::OnWp::CreateAppuntamento.call(pratica: @pratica, hub:, tipologia:, data:, durata_giorni:)
end
end
Le regole per WordPress valgono per tutta la migrazione: su WP si interviene solo disattivando, i dati arrivano a WP solo via REST standard, le inversioni pesanti si fanno solo in bassa stagione.
Il controller non chiede "sei admin?" ma "puoi fare questa azione?". Una matrice in Accessi::Permessi lega ogni ruolo alle azioni che può compiere. Aggiungere un ruolo o spostare un'azione si fa in un punto solo.
class AppuntamentiController < ApplicationController
before_action do
unless current_user.admin? || current_user.operation?
redirect_to root_path, alert: 'Non autorizzato'
end
end
end
# Il giorno in cui nasce gestore_tecnici bisogna
# trovare tutti gli "admin? || operation?" e decidere.
MATRICE = {
'admin' => AZIONI,
'operation' => %i[agenda_gestisci flotta_legge trasporti_gestisci …],
'gestore_tecnici' => %i[agenda_gestisci tecnici_assegna tecnici_anagrafica …],
'logistica' => %i[flotta_gestisci flotta_legge trasporti_gestisci],
'expert' => [], 'amministrazione' => [], 'commerciale' => [], 'callcenter' => []
}.freeze
# Azione sconosciuta = ArgumentError:
# un refuso non deve negare in silenzio.
def self.consentito?(user, azione) …
class Pratiche::AppuntamentiController < ApplicationController
before_action -> { richiedi_permesso!(:agenda_gestisci) }
end
I ruoli expert, amministrazione, commerciale e callcenter esistono ma non hanno ancora azioni: le riceveranno nelle fasi che li riguardano.
Esempio: "l'operatore può spostare un appuntamento su un altro hub, e l'hub di destinazione riceve un avviso". Ecco come la si scrive dopo F1.
ModificaAppuntamento con hub tra gli attributi. Un'intenzione nuova (per esempio "sposta per indisponibilità") merita un comando suo, con un nome del business.RegolaViolata."Non si sposta un appuntamento annullato" è già :appuntamento_annullato. Codici nuovi vanno in dominio.it.yml.Current.attore; da un job lo si eredita o si usa con_attore.pia.appuntamento.modificato con i campi cambiati esiste già. Un fatto nuovo segue la convenzione pia.<aggregato>.<participio>.include Eventi::Idempotente, il lavoro dentro una_volta(event), la registrazione in event_subscribers.rb.CAMPI_POSSEDUTI. Un campo che Support non possiede resta fuori dal payload.richiedi_permesso!(:agenda_gestisci), o un'azione nuova nella matrice.bin/packwerk check.Se segnala un riferimento privato, si usa la facciata o si sposta il file in app/public/. update-todo non serve a nascondere violazioni nuove.L'elenco completo delle attenzioni, con cosa succede se le si salta, è nella pagina Storm Support dopo F1. Il quadro dell'intero progetto è in Migrazione a Storm Support.
packs/legacy_wp.Current.attore.pia.appuntamento.annullato.app/public/ di un pacchetto: l'unica parte che gli altri pacchetti possono usare.kernel_eventi) in cui l'evento si scrive nella stessa transazione del dato, per consegnarlo dopo il commit.emit(event) che reagisce a un evento.