Storm Support · guida per chi viene dalla Rails Way

Paradigmi della migrazione

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.

In una tabella

Mappa rapida

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 pianoNella migrazioneDove guardare
Tutto in app/models, ogni classe può usare ogni altraUn pacchetto per contesto, con una facciata pubblica; packwerk blocca gli accessi non dichiaratipacks/*/package.yml
Appuntamento.create nel controller, effetti nelle callback del modelloUn comando con un nome di dominio: FissaAppuntamentopacks/pianificazione/app/public/…/comandi
validate ed errors.addPer le regole di business: raise Dominio::RegolaViolata, :codicepacks/kernel/…/regola_violata.rb
current_user, che esiste solo nel controllerCurrent.attore, che arriva anche nei job, negli audit e negli eventipacks/kernel/app/public/attore.rb
after_commit { SyncJob.perform_later }Eventi.pubblica('pia.appuntamento.fissato', …) con outboxpacks/kernel/app/public/eventi.rb
Un job che si dà per eseguito una volta solaSubscriber e job che reggono la doppia esecuzioneEventi::Idempotente
Chiamate all'API di WP sparse nel codiceTutto ciò che riguarda WP sta in un pacchetto che nessuno importapacks/legacy_wp
if current_user.admin?richiedi_permesso!(:agenda_gestisci) su una matrice ruolo → azioniAccessi::Permessi
Rilascio e viaIl comportamento nuovo è dietro un flag e si accende in bassa stagioneFeature.active?(:agenda_comandi_locali)
Il contesto

Perché la Rails Way non basta qui

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.

Due master

Lo stesso appuntamento vive in due sistemi

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.

Molti ingressi

Chi scrive non è sempre un operatore

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.

Molti domini

Quattordici aree di business

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.

Spegnimento

Il codice di WP deve poter sparire

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.

Il vocabolario

DDD in cinque idee

Domain-Driven Design è un insieme di pratiche per organizzare il codice attorno al business invece che attorno alle tabelle. Ne usiamo cinque idee.

1. Sottodomini: dove investire

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.

2. Bounded context: la stessa parola, significati diversi

Un bounded context è un confine dentro cui ogni parola ha un solo significato. "Appuntamento" è l'esempio più chiaro.

Pianificazione

Un posto in agenda

Una pratica, un hub, una tipologia (perizia, lavorazione), una data, la capacità che consuma. Si fissa, si sposta, si annulla.

Logistica

Un movimento del veicolo

Pickup, delivery, bisarca, sostitutiva. Qui conta dove sta l'auto e chi la porta, non lo slot in agenda.

WordPress (LegacyWp)

Un post con campi ACF

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.

3. Linguaggio ubiquo: il codice parla come l'operatore

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.

4. Aggregato: l'unità che si modifica insieme

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.

5. Value object: valori immutabili

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.

F1-01 · struttura

Contesti come pacchetti nel monolite

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.

Dipendenze dichiarate tra i pacchetti F1 · la freccia va da chi usa a chi è usato
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.

Rails pianonessun confine
# 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
F1packs/pianificazione/package.yml
# 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
  • Se domani Logistica volesse scrivere su Appuntamento, bin/packwerk check fallirebbe in CI.
  • Ogni pacchetto ha anche un prefisso per le sue tabelle: pianificazione_, legacy_wp_, kernel_.
Cosa packwerk non vede

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.

F1-04 · scrittura

Comandi invece di create e callback

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.

Rails pianocontroller + callback
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
F1pianificazione/comandi/fissa_appuntamento.rb
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
  • Gli effetti sono nascosti nel modello: leggendo il controller non si sa che parte una chiamata a WP.
  • Un update_column o un insert_all salta le callback senza avvisare.
  • Il modello conosce WP, il canale di comunicazione con il cliente e tutto il resto.
  • Il comando dice cosa succede e lo fa in una transazione. Il modello torna a essere dati e regole semplici.
  • Il comando non chiama WP: pubblica un fatto, e la proiezione su WP è un effetto di quel fatto (vedi LegacyWp).
  • L'attore è un parametro obbligatorio: non si scrive senza sapere chi lo fa.

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).

F1-01 · kernel

Regole di dominio come eccezioni con un codice

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.

Rails pianovalidazione sul modello
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.
F1regola nel comando, gestione nel controller
# 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.

F1-01 · kernel

L'attore: chi ha fatto la cosa

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.

Il viaggio dell'attore quando un operatore fissa un appuntamento
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"]
Rails pianoil job non sa chi
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
F1kernel/app/public/attore.rb · application_job.rb
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.

F1-01 · kernel

Eventi di dominio: pubblicare un fatto, non chiamare chi reagisce

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.

Fissare un appuntamento con il flag acceso
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
Rails pianoil modello conosce tutti
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.
F1la busta che arriva al subscriber
{
  "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"
}
  • Ogni reazione nuova tocca il modello, e ogni test del modello si porta dietro tutte le reazioni.
  • Le reazioni si registrano in 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.

F1-01 · kernel

Outbox transazionale: l'evento non si perde

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.

Dentro la transazione

Il job parte prima del commit

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.

Dopo il commit

Il processo muore nel mezzo

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.

Outbox

L'evento è un dato

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.

Ciclo di vita di una riga di kernel_eventi
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
F1packs/kernel/app/public/eventi.rb (essenziale)
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
La garanzia è "almeno una volta"

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.

F1-01, F1-03, F1-04

Idempotenza: eseguire due volte deve dare lo stesso risultato

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.

LivelloCosa può ripetersiDifesa in F1
SubscriberLo stesso evento consegnato due volteEventi::Idempotente#una_volta: una riga in kernel_eventi_elaborati con indice unico (event_id, subscriber), scritta nella stessa transazione del lavoro
Richiesta di proiezioneLo stesso pia.appuntamento.* due volteProiezione.richiedi! confronta ultimo_event_id e non accoda un secondo job
ComandoLo sweep notturno ripassa su un appuntamento già annullatoAnnullaAppuntamento: next appuntamento if appuntamento.annullato?
Chiamata a WPcreate_post va in timeout dopo l'insert, il job ritentaOgni post nasce con slug ss-<uuid>; prima di creare si cerca per slug e, se esiste, lo si riprende
Prima di F1actions/on_wp/create_appuntamento.rb (reale)
# 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
F1legacy_wp/proietta.rb · un subscriber d'esempio
# 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.

F1-03 · sync inversa

Lo strato LegacyWp: WordPress diventa una proiezione

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.

Prima di F1 · WP master
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
Dopo F1 · Support master
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

I pezzi dello strato

Registro

Cosa si proietta

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.

Adattatore

Come si traduce

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.

Proiezione

A che punto è

Una riga per record in legacy_wp_proiezioni: stato, ultimo payload riuscito, tentativi, ultimo errore. Le fallite si vedono nel cruscotto admin.

ProiettaJob

Chi la esegue

Coda wp_write, uno alla volta per record (limits_concurrency), 5 tentativi con attesa crescente. Dopo l'ultimo la proiezione è fallita.

FiltroEco

Cosa ignorare

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.

Stati di una proiezione
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
Il webhook di ritorno è un'eco?
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"]

La regola "sconosciuto non vuol dire vuoto"

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.

Rails pianopayload completo a ogni save
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
F1legacy_wp/adattatori/base.rb
# 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
Strategia

Un dominio alla volta, dietro un flag

Ogni dominio passa a Support in quattro passi. L'agenda appuntamenti è il primo a percorrerli.

Modellare

Il dominio esiste in Support con comandi ed eventi. Per l'agenda: i tre comandi di F1-04.

Migrare

I dati di WP arrivano e restano allineati in Support. Per l'agenda la sync da WP esisteva già.

Invertire

Support diventa master e WP riceve la proiezione. Si accende il flag, in bassa stagione (novembre–marzo).

Spegnere

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.

F1app/controllers/pratiche/appuntamenti_controller.rb
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.

F1-02 · accessi

Permessi per azione, non per ruolo

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.

Rails pianoruoli controllati sparsi
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.
F1accessi/permessi.rb · controller
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.

In pratica

Una feature nuova, passo per passo

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.

  1. Trova il contesto.Spostare un appuntamento è Pianificazione. L'avviso all'hub è una reazione, non parte del comando.
  2. Scrivi o estendi un comando.Qui basta ModificaAppuntamento con hub tra gli attributi. Un'intenzione nuova (per esempio "sposta per indisponibilità") merita un comando suo, con un nome del business.
  3. Esprimi le regole come RegolaViolata."Non si sposta un appuntamento annullato" è già :appuntamento_annullato. Codici nuovi vanno in dominio.it.yml.
  4. Passa l'attore.Dal controller Current.attore; da un job lo si eredita o si usa con_attore.
  5. Pubblica un fatto al passato.pia.appuntamento.modificato con i campi cambiati esiste già. Un fatto nuovo segue la convenzione pia.<aggregato>.<participio>.
  6. Reagisci con un subscriber idempotente.include Eventi::Idempotente, il lavoro dentro una_volta(event), la registrazione in event_subscribers.rb.
  7. Se WP legge il dato, controlla il registro.Un campo nuovo che WP deve vedere va nell'adattatore e in CAMPI_POSSEDUTI. Un campo che Support non possiede resta fuori dal payload.
  8. Proteggi l'ingresso con un'azione.richiedi_permesso!(:agenda_gestisci), o un'azione nuova nella matrice.
  9. Fai girare 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.

Riferimento

Glossario

Aggregato
Gruppo di oggetti che cambiano insieme, in una transazione, da un solo punto d'ingresso. Esempio: l'appuntamento.
Anti-corruption layer
Strato di traduzione tra il modello nuovo e quello di un sistema esterno. Da noi: packs/legacy_wp.
Attore
Chi compie una scrittura: utente, job, utente WP, import, cliente da form. Value object in Current.attore.
Bounded context
Confine dentro cui una parola ha un solo significato. Da noi coincide con un pacchetto.
Busta
I metadati comuni di ogni evento: event_id, occurred_at, actor, origin, aggregate_type, aggregate_id, version.
Comando
Oggetto che esegue un'intenzione del business in una transazione e pubblica l'evento corrispondente.
Evento di dominio
Fatto avvenuto, al passato, pubblicato senza sapere chi lo ascolta. Esempio: pia.appuntamento.annullato.
Facciata
La cartella app/public/ di un pacchetto: l'unica parte che gli altri pacchetti possono usare.
Idempotenza
Proprietà di un'operazione che, ripetuta, dà lo stesso risultato.
Linguaggio ubiquo
Le parole del business usate uguali nel codice: fissare, annullare, pratica, hub.
Outbox
Tabella (kernel_eventi) in cui l'evento si scrive nella stessa transazione del dato, per consegnarlo dopo il commit.
packwerk
Strumento che controlla in CI le dipendenze e la privacy tra pacchetti.
Proiezione
Copia di un record di Support scritta su WP perché WP la legge ancora.
Strangler fig
Sostituzione graduale di un sistema vecchio, una funzione alla volta.
Subscriber
Oggetto con un metodo emit(event) che reagisce a un evento.
Value object
Oggetto immutabile che conta per il suo valore e non ha identità propria.