<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://antoninoscaffidi.github.io/it/feed.xml" rel="self" type="application/atom+xml" /><link href="https://antoninoscaffidi.github.io/it/" rel="alternate" type="text/html" /><updated>2026-08-22T05:18:30+00:00</updated><id>https://antoninoscaffidi.github.io/feed.xml</id><title type="html">Antonino Scaffidi Chiarello</title><subtitle>A blog about Ruby on Rails, web development, and AI — tutorials, deep dives, and project series like VicinoTe.</subtitle><entry xml:lang="it"><title type="html">Ricerca semantica sulle conversazioni RubyLLM con pgvector</title><link href="https://antoninoscaffidi.github.io/it/semantic-search-with-pgvector/" rel="alternate" type="text/html" title="Ricerca semantica sulle conversazioni RubyLLM con pgvector" /><published>2026-08-22T05:00:00+00:00</published><updated>2026-08-22T05:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/semantic-search-with-pgvector.it</id><content type="html" xml:base="https://antoninoscaffidi.github.io/semantic-search-with-pgvector/"><![CDATA[<p>L’<a href="/it/streaming-responses-with-turbo-streams/">episodio 4</a> aveva fatto scrivere la risposta da sola, in diretta. Questo episodio fa qualcosa di diverso con la stessa cronologia delle conversazioni: cercarla per <strong>significato</strong>, non per corrispondenza di parole. Chiedi “cucinare la cena” e ottieni indietro il messaggio in cui l’assistente ha spiegato come arrostire un pollo, anche se la parola “cucinare” non compare mai al suo interno.</p>

<p>Questo è l’episodio per cui la serie si sta preparando dall’<a href="/it/introduction-to-rubyllm/">episodio 1</a>, che aveva scelto PostgreSQL apposta perché questo fosse possibile più avanti senza una migrazione di database a metà serie. Oggi è quel “più avanti”. Il codice è taggato <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo/tree/episode-5"><code class="language-plaintext highlighter-rouge">episode-5</code></a> nel repo <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo">ai-with-ruby-demo</a>. Come sempre, niente resta inspiegato — inclusi due errori veri incontrati costruendolo, con i messaggi esatti e le correzioni.</p>

<h2 id="cosa-tocca-questo-episodio-in-sintesi">Cosa tocca questo episodio, in sintesi</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>config/database.yml                 modifica — fuori SQLite, dentro PostgreSQL
Gemfile                             modifica — pg al posto di sqlite3, aggiunge la gemma neighbor
db/migrate/..._install_neighbor_vector.rb   nuovo — abilita l'estensione Postgres pgvector
db/migrate/..._add_embedding_to_messages.rb nuovo — una colonna vector(1536) più un indice HNSW
app/models/message.rb               modifica — has_neighbors, e un after_commit che genera l'embedding dei nuovi contenuti
app/jobs/embed_message_job.rb       nuovo — chiama RubyLLM.embed e salva il vettore
app/controllers/search_controller.rb nuovo — genera l'embedding della query, trova i messaggi più vicini
app/views/search/show.html.erb      nuovo — il form di ricerca e i risultati
config/routes.rb                    modifica — resource :search
app/views/chats/new.html.erb        modifica — un link alla nuova pagina di ricerca
</code></pre></div></div>

<h2 id="perché-serve-postgres-non-sqlite">Perché serve Postgres, non SQLite</h2>

<p>Un embedding testuale è una lista di numeri — per il modello usato in questo episodio, 1.536 — posizionati in modo che pezzi di testo con <strong>significato</strong> simile finiscano come punti vicini in quello spazio a 1.536 dimensioni. “Trovare messaggi simili” diventa “trovare i punti più vicini,” un problema computazionale reale e ben studiato, non qualcosa che si aggiunge a una normale colonna di database.</p>

<p><a href="https://github.com/pgvector/pgvector">pgvector</a> è un’estensione PostgreSQL che aggiunge un tipo di colonna <code class="language-plaintext highlighter-rouge">vector</code> nativo e l’indicizzazione e gli operatori di distanza necessari per cercarla in modo efficiente. Serve PostgreSQL nello specifico — è un’estensione di Postgres, non una gemma — e una versione abbastanza recente (13+). L’app demo girava su SQLite dall’episodio 1, quindi il primo vero passo di questo episodio non è stato codice Ruby: configurare un server PostgreSQL con pgvector disponibile, poi migrarci l’app.</p>

<p>Quella parte è per lo più una configurazione dell’ambiente una tantum piuttosto che qualcosa che questo post deve ripercorrere riga per riga (i passi esatti dipendono da come già usi Postgres — Docker, Homebrew, Postgres.app, un servizio gestito). L’unica cosa da segnalare se ci incappi: compilare pgvector da sorgente su macOS con <a href="https://postgresapp.com">Postgres.app</a> può fallire con <code class="language-plaintext highlighter-rouge">clang: error: the clang compiler does not support '-march=native'</code>, perché Postgres.app distribuisce binari universali (<code class="language-plaintext highlighter-rouge">arm64</code> + <code class="language-plaintext highlighter-rouge">x86_64</code> combinati) e il <code class="language-plaintext highlighter-rouge">Makefile</code> di pgvector usa di default un flag di ottimizzazione specifico per architettura che non si applica a una build universale. La correzione, <a href="https://github.com/pgvector/pgvector/blob/master/Makefile">documentata proprio nel commento del Makefile stesso</a>: <code class="language-plaintext highlighter-rouge">make OPTFLAGS=""</code> invece di un semplice <code class="language-plaintext highlighter-rouge">make</code>.</p>

<h2 id="cambiare-database">Cambiare database</h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/database.yml</span>
<span class="na">default</span><span class="pi">:</span> <span class="nl">&amp;default</span>
  <span class="na">adapter</span><span class="pi">:</span> <span class="s">postgresql</span>
  <span class="na">encoding</span><span class="pi">:</span> <span class="s">unicode</span>
  <span class="na">host</span><span class="pi">:</span> <span class="s">localhost</span>
  <span class="na">port</span><span class="pi">:</span> <span class="m">5433</span>
  <span class="na">pool</span><span class="pi">:</span> <span class="s">&lt;%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %&gt;</span>

<span class="na">development</span><span class="pi">:</span>
  <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
  <span class="na">database</span><span class="pi">:</span> <span class="s">ai_with_ruby_demo_development</span>

<span class="na">test</span><span class="pi">:</span>
  <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
  <span class="na">database</span><span class="pi">:</span> <span class="s">ai_with_ruby_demo_test</span>

<span class="na">production</span><span class="pi">:</span>
  <span class="na">primary</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
    <span class="na">database</span><span class="pi">:</span> <span class="s">ai_with_ruby_demo_production</span>
  <span class="na">cache</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
    <span class="na">database</span><span class="pi">:</span> <span class="s">ai_with_ruby_demo_production_cache</span>
    <span class="na">migrations_paths</span><span class="pi">:</span> <span class="s">db/cache_migrate</span>
  <span class="na">queue</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
    <span class="na">database</span><span class="pi">:</span> <span class="s">ai_with_ruby_demo_production_queue</span>
    <span class="na">migrations_paths</span><span class="pi">:</span> <span class="s">db/queue_migrate</span>
  <span class="na">cable</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
    <span class="na">database</span><span class="pi">:</span> <span class="s">ai_with_ruby_demo_production_cable</span>
    <span class="na">migrations_paths</span><span class="pi">:</span> <span class="s">db/cable_migrate</span>
</code></pre></div></div>

<p>La forma è identica alla versione SQLite dell’episodio 1 — stessi tre environment, stessa produzione divisa in <code class="language-plaintext highlighter-rouge">primary</code>/<code class="language-plaintext highlighter-rouge">cache</code>/<code class="language-plaintext highlighter-rouge">queue</code>/<code class="language-plaintext highlighter-rouge">cable</code> per il trio Solid — è cambiato solo <code class="language-plaintext highlighter-rouge">adapter:</code> e i dettagli di connessione. <code class="language-plaintext highlighter-rouge">port: 5433</code> è specifico a come Postgres si trova a girare sulla macchina su cui è stato costruito questo (un secondo server, accanto a uno esistente sulla porta di default <code class="language-plaintext highlighter-rouge">5432</code>, così i due non entrano in conflitto) — il numero in sé non è significativo, conta solo che corrisponda a dove il tuo Postgres è davvero in ascolto.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="c1"># Use PostgreSQL as the database for Active Record</span>
<span class="n">gem</span> <span class="s2">"pg"</span><span class="p">,</span> <span class="s2">"~&gt; 1.5"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">sqlite3</code> è uscita, <code class="language-plaintext highlighter-rouge">pg</code> (il driver PostgreSQL) è entrata. Poi:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">install
</span>bin/rails db:create db:migrate
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">db:create</code> crea da zero i database (vuoti) <code class="language-plaintext highlighter-rouge">ai_with_ruby_demo_development</code> e <code class="language-plaintext highlighter-rouge">_test</code> — questo è un server Postgres nuovo, quindi non c’è nessun dato da migrare dai vecchi file SQLite, solo lo schema. <code class="language-plaintext highlighter-rouge">db:migrate</code> rigioca ogni migrazione degli episodi 3 e 4 (conversations, messages, tool_calls, models, le foreign key tra loro) sul nuovo database, finendo esattamente nella stessa forma che aveva SQLite.</p>

<h2 id="abilitare-pgvector">Abilitare pgvector</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._install_neighbor_vector.rb</span>
<span class="k">class</span> <span class="nc">InstallNeighborVector</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">8.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">enable_extension</span> <span class="s2">"vector"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Generata, non scritta a mano — è esattamente l’output di <code class="language-plaintext highlighter-rouge">rails generate neighbor:vector</code>, dal <a href="https://github.com/ankane/neighbor">gem neighbor</a>, aggiunto al <code class="language-plaintext highlighter-rouge">Gemfile</code> accanto a <code class="language-plaintext highlighter-rouge">pg</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"neighbor"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">neighbor</code> è ciò che dà ad ActiveRecord un tipo di colonna <code class="language-plaintext highlighter-rouge">vector</code> e dei metodi di query per i vicini più prossimi sopra pgvector (supporta anche un paio di altri backend, non usati qui). <code class="language-plaintext highlighter-rouge">enable_extension "vector"</code> è una riga sola, ma fa una cosa vera a livello di database: esegue il <code class="language-plaintext highlighter-rouge">CREATE EXTENSION vector</code> di Postgres stesso, che ha successo solo se l’estensione pgvector è davvero installata sul server — è la migrazione che fallirebbe per prima se pgvector non fosse configurato correttamente.</p>

<h2 id="la-colonna-embedding">La colonna embedding</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._add_embedding_to_messages.rb</span>
<span class="k">class</span> <span class="nc">AddEmbeddingToMessages</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">8.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="c1"># 1536 dimensions matches OpenAI's text-embedding-3-small, RubyLLM's default.</span>
    <span class="n">add_column</span> <span class="ss">:messages</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">,</span> <span class="ss">:vector</span><span class="p">,</span> <span class="ss">limit: </span><span class="mi">1536</span>
    <span class="n">add_index</span> <span class="ss">:messages</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">,</span> <span class="ss">using: :hnsw</span><span class="p">,</span> <span class="ss">opclass: :vector_cosine_ops</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">add_column :messages, :embedding, :vector, limit: 1536</code> — <code class="language-plaintext highlighter-rouge">:vector</code> qui è un tipo di colonna vero, non un blob JSON o un array serializzato; pgvector lo aggiunge a Postgres stesso, e <code class="language-plaintext highlighter-rouge">neighbor</code> insegna al DSL delle migrazioni di Rails a parlarlo. <code class="language-plaintext highlighter-rouge">limit: 1536</code> fissa la lunghezza del vettore. Quel numero non è arbitrario — deve corrispondere a qualsiasi modello di embedding produca davvero i vettori, e <code class="language-plaintext highlighter-rouge">text-embedding-3-small</code> di OpenAI (il modello di embedding di default di RubyLLM, trattato sotto) restituisce sempre esattamente 1.536 numeri. Sbagliarlo fa fallire ogni inserimento con un errore di dimensioni non corrispondenti, rumorosamente, al primo tentativo.</li>
  <li><code class="language-plaintext highlighter-rouge">add_index :messages, :embedding, using: :hnsw, opclass: :vector_cosine_ops</code> — senza un indice, “trova i vettori più vicini” significa confrontare la query con ogni singola riga, una alla volta — va bene per una demo con una manciata di messaggi, rovinoso su scala reale. HNSW (Hierarchical Navigable Small World) è una struttura di indice per vicini-più-prossimi approssimati costruita proprio per questo: lookup veloci che hanno una probabilità altissima di trovare i punti davvero più vicini senza la garanzia di una scansione completa. <code class="language-plaintext highlighter-rouge">opclass: :vector_cosine_ops</code> dice all’indice per quale funzione di distanza ottimizzare — la <strong>distanza coseno</strong>, che misura l’<strong>angolo</strong> tra due vettori invece della distanza in linea retta tra loro. Per gli embedding testuali, il coseno è la scelta convenzionale: cattura “questi significano cose simili” indipendentemente da quanto fosse lunga ciascuna stringa, cosa che una misura di distanza grezza confonderebbe con la vera dissimilarità.</li>
</ul>

<h2 id="rubyllmembed-dal-sorgente">RubyLLM.embed, dal sorgente</h2>

<p>Prima di scrivere il codice che lo chiama, la stessa abitudine di ogni episodio finora: leggere cosa fa davvero. <code class="language-plaintext highlighter-rouge">RubyLLM.embed</code> delega a <a href="https://github.com/crmne/ruby_llm"><code class="language-plaintext highlighter-rouge">RubyLLM::Embedding.embed</code></a>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">embed</span><span class="p">(</span><span class="n">text</span><span class="p">,</span> <span class="ss">model: </span><span class="kp">nil</span><span class="p">,</span> <span class="ss">provider: </span><span class="kp">nil</span><span class="p">,</span> <span class="ss">assume_model_exists: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">context: </span><span class="kp">nil</span><span class="p">,</span> <span class="ss">dimensions: </span><span class="kp">nil</span><span class="p">)</span>
  <span class="n">config</span> <span class="o">=</span> <span class="n">context</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">config</span> <span class="o">||</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">config</span>
  <span class="n">model</span> <span class="o">||=</span> <span class="n">config</span><span class="p">.</span><span class="nf">default_embedding_model</span>
  <span class="n">model</span><span class="p">,</span> <span class="n">provider_instance</span> <span class="o">=</span> <span class="no">Models</span><span class="p">.</span><span class="nf">resolve</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="ss">provider: </span><span class="n">provider</span><span class="p">,</span> <span class="ss">assume_exists: </span><span class="n">assume_model_exists</span><span class="p">,</span> <span class="ss">config: </span><span class="n">config</span><span class="p">)</span>
  <span class="n">model_id</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="nf">id</span>

  <span class="n">payload</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">provider: </span><span class="n">provider_instance</span><span class="p">.</span><span class="nf">slug</span><span class="p">,</span> <span class="ss">provider_class: </span><span class="n">provider_instance</span><span class="p">.</span><span class="nf">class</span><span class="p">.</span><span class="nf">name</span><span class="p">,</span>
              <span class="ss">model: </span><span class="n">model_id</span><span class="p">,</span> <span class="ss">model_info: </span><span class="n">model</span><span class="p">,</span> <span class="ss">input: </span><span class="n">text</span><span class="p">,</span> <span class="ss">dimensions: </span><span class="n">dimensions</span> <span class="p">}</span>

  <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">instrument</span><span class="p">(</span><span class="s1">'embedding.ruby_llm'</span><span class="p">,</span> <span class="n">payload</span><span class="p">,</span> <span class="ss">config: </span><span class="n">config</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
    <span class="n">result</span> <span class="o">=</span> <span class="n">provider_instance</span><span class="p">.</span><span class="nf">embed</span><span class="p">(</span><span class="n">text</span><span class="p">,</span> <span class="ss">model: </span><span class="n">model_id</span><span class="p">,</span> <span class="n">dimensions</span><span class="p">:)</span>
    <span class="n">event</span><span class="p">[</span><span class="ss">:result</span><span class="p">]</span> <span class="o">=</span> <span class="n">result</span>
    <span class="n">result</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Alcune cose che vale la pena tirare fuori:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">model ||= config.default_embedding_model</code> — chiama <code class="language-plaintext highlighter-rouge">RubyLLM.embed("qualche testo")</code> senza nessuna opzione, e ricade su un default configurato invece di richiedere un nome di modello ogni volta. Quel default, dalla <code class="language-plaintext highlighter-rouge">Configuration</code> di RubyLLM stessa, è <code class="language-plaintext highlighter-rouge">'text-embedding-3-small'</code> — che è esattamente perché la migrazione sopra aveva <code class="language-plaintext highlighter-rouge">limit: 1536</code> fisso: è la dimensione di output di questo specifico modello, e nulla nel codice impone che i due restino sincronizzati se uno dei due cambia più avanti.</li>
  <li><code class="language-plaintext highlighter-rouge">Models.resolve(model, ...)</code> — lo stesso meccanismo di risoluzione dei modelli che usano <code class="language-plaintext highlighter-rouge">ask</code>/<code class="language-plaintext highlighter-rouge">complete</code> per la chat, riusato qui. È anche da dove è arrivato il primo vero errore di questo episodio, trattato subito dopo.</li>
  <li>Il metodo restituisce un <code class="language-plaintext highlighter-rouge">RubyLLM::Embedding</code>, il cui attributo <code class="language-plaintext highlighter-rouge">.vectors</code> è il vero array di float — è quello che viene assegnato direttamente nella colonna <code class="language-plaintext highlighter-rouge">embedding</code>.</li>
</ul>

<h2 id="un-errore-vero-il-modello-di-embedding-non-era-nel-registro">Un errore vero: il modello di embedding non era nel registro</h2>

<p>La prima volta che <code class="language-plaintext highlighter-rouge">EmbedMessageJob</code> ha davvero girato (trattato per intero sotto), è fallito immediatamente:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RubyLLM::ModelNotFoundError (Unknown model: "text-embedding-3-small". If the model exists
at the provider, refresh the registry with `RubyLLM.models.refresh!` and persist it with
`RubyLLM.models.save_to_json`. Rails model registries can call `Model.refresh!` instead.)
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Models.resolve</code> sopra cerca il nome del modello nel registro di modelli conosciuti di RubyLLM stesso — non chiedendo a OpenAI al momento della richiesta, ma contro una lista locale. L’episodio 3 aveva menzionato che questo registro esiste (la tabella <code class="language-plaintext highlighter-rouge">models</code>, popolata da <code class="language-plaintext highlighter-rouge">bin/rails ruby_llm:load_models</code>) ma non aveva mai davvero eseguito quel task, dato che fino a quel punto nulla ne dipendeva — i modelli di chat passavano dritti senza che nessun lookup nel registro dovesse riuscire su niente di insolito. La ricerca semantica è la prima funzionalità di questa serie ad avere davvero bisogno del registro popolato, e non lo era:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails ruby_llm:load_models
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>✅ Loaded 1166 models into database
</code></pre></div></div>

<p>Un comando, eseguito una volta, e l’errore è sparito. Vale la pena ricordarlo come forma generale di bug: non tutto ciò che sembra un errore dell’applicazione lo è — questo era un passo di configurazione una tantum di cui l’app aveva sempre avuto bisogno, che semplicemente non aveva avuto nessun sintomo visibile finché questa esatta funzionalità non l’ha messo alla prova.</p>

<h2 id="generare-un-embedding-ogni-volta-che-un-messaggio-ottiene-contenuto-vero">Generare un embedding ogni volta che un messaggio ottiene contenuto vero</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/message.rb</span>
<span class="k">class</span> <span class="nc">Message</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">acts_as_message</span> <span class="ss">chat: :conversation</span>
  <span class="n">has_neighbors</span> <span class="ss">:embedding</span>

  <span class="n">broadcasts_to</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">message</span><span class="p">)</span> <span class="p">{</span> <span class="s2">"conversation_</span><span class="si">#{</span><span class="n">message</span><span class="p">.</span><span class="nf">conversation_id</span><span class="si">}</span><span class="s2">"</span> <span class="p">},</span> <span class="ss">inserts_by: :append</span>

  <span class="n">after_commit</span> <span class="ss">:enqueue_embedding</span><span class="p">,</span> <span class="ss">on: </span><span class="sx">%i[create update]</span><span class="p">,</span> <span class="ss">if: </span><span class="o">-&gt;</span> <span class="p">{</span> <span class="n">content</span><span class="p">.</span><span class="nf">present?</span> <span class="o">&amp;&amp;</span> <span class="n">saved_change_to_content?</span> <span class="p">}</span>

  <span class="k">def</span> <span class="nf">broadcast_append_chunk</span><span class="p">(</span><span class="n">content</span><span class="p">)</span>
    <span class="n">broadcast_append_to</span> <span class="s2">"conversation_</span><span class="si">#{</span><span class="n">conversation_id</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span>
      <span class="ss">target: </span><span class="s2">"message_</span><span class="si">#{</span><span class="nb">id</span><span class="si">}</span><span class="s2">_content"</span><span class="p">,</span>
      <span class="ss">content: </span><span class="no">ERB</span><span class="o">::</span><span class="no">Util</span><span class="p">.</span><span class="nf">html_escape</span><span class="p">(</span><span class="n">content</span><span class="p">.</span><span class="nf">to_s</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">enqueue_embedding</span>
    <span class="no">EmbedMessageJob</span><span class="p">.</span><span class="nf">perform_later</span><span class="p">(</span><span class="nb">id</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Due aggiunte al file lasciato dall’episodio 4:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">has_neighbors :embedding</code> — dal gem <code class="language-plaintext highlighter-rouge">neighbor</code>, è ciò che rende disponibile <code class="language-plaintext highlighter-rouge">.nearest_neighbors</code> (usato nel controller di ricerca sotto) su <code class="language-plaintext highlighter-rouge">Message</code> in primo luogo. Nient’altro che una dichiarazione; la colonna e l’indice veri vengono dalle migrazioni sopra.</li>
  <li><code class="language-plaintext highlighter-rouge">after_commit :enqueue_embedding, on: %i[create update], if: -&gt; { content.present? &amp;&amp; saved_change_to_content? }</code> — questa riga ha richiesto un secondo passaggio per essere giusta, e il ragionamento dietro ogni pezzo conta:
    <ul>
      <li><strong>Perché <code class="language-plaintext highlighter-rouge">after_commit</code>, non <code class="language-plaintext highlighter-rouge">after_save</code>.</strong> <code class="language-plaintext highlighter-rouge">enqueue_embedding</code> chiama <code class="language-plaintext highlighter-rouge">perform_later</code>, che con l’adapter <code class="language-plaintext highlighter-rouge">:async</code> usato qui può far partire il job su un altro thread quasi immediatamente. Se il <code class="language-plaintext highlighter-rouge">Message.find_by(id: ...)</code> di quel job girasse prima che la transazione che ha creato la riga fosse davvero committata, potrebbe non trovare nulla. <code class="language-plaintext highlighter-rouge">after_commit</code> garantisce che la riga sia salvata in modo durevole prima che venga arruolato qualsiasi cosa contro di essa.</li>
      <li><strong>Perché <code class="language-plaintext highlighter-rouge">on: %i[create update]</code>, non solo <code class="language-plaintext highlighter-rouge">:create</code>.</strong> Ricorda dall’episodio 4: un <code class="language-plaintext highlighter-rouge">Message</code> per una risposta assistant viene creato <strong>vuoto</strong> (<code class="language-plaintext highlighter-rouge">content: ''</code>, nel callback <code class="language-plaintext highlighter-rouge">before_message</code> di RubyLLM) e riempito con testo vero solo più tardi, tramite un <code class="language-plaintext highlighter-rouge">UPDATE</code> una volta che il modello finisce lo streaming. La sola creazione vedrebbe sempre e solo contenuto vuoto — l’update è dove il vero testo dell’assistant atterra davvero.</li>
      <li><strong>Perché <code class="language-plaintext highlighter-rouge">content.present?</code>.</strong> Senza, quella creazione a contenuto vuoto farebbe comunque scattare il callback, e <code class="language-plaintext highlighter-rouge">RubyLLM.embed("")</code> o darebbe errore o sprecherebbe una chiamata API per generare l’embedding di niente.</li>
      <li><strong>Perché <code class="language-plaintext highlighter-rouge">saved_change_to_content?</code>, oltre a <code class="language-plaintext highlighter-rouge">content.present?</code>.</strong> Questa è quella che non è ovvia. Ricorda dall’episodio 4 che <code class="language-plaintext highlighter-rouge">add_message</code> fa anche un <code class="language-plaintext highlighter-rouge">.update!(content_raw:)</code> incidentale subito dopo aver creato il messaggio dell’utente — un secondo <code class="language-plaintext highlighter-rouge">UPDATE</code> dove <code class="language-plaintext highlighter-rouge">content</code> in sé non cambia. Senza questo controllo, quell’update incidentale passerebbe il test <code class="language-plaintext highlighter-rouge">content.present?</code> (il contenuto c’è, solo invariato) e innescherebbe una seconda, sprecata chiamata di embedding per un testo che era già stato embeddato pochi istanti prima alla creazione. <code class="language-plaintext highlighter-rouge">saved_change_to_content?</code> è il modo di Rails stesso di chiedere “questo specifico attributo è davvero cambiato nel salvataggio appena committato” — vero alla creazione (nil → il testo del messaggio) e vero quando il placeholder dell’assistant viene riempito (<code class="language-plaintext highlighter-rouge">''</code> → la vera risposta), falso per quell’update incidentale che tocca solo <code class="language-plaintext highlighter-rouge">content_raw</code>. L’effetto netto: esattamente due chiamate di embedding per scambio, una per la domanda, una per la risposta, mai una terza.</li>
    </ul>
  </li>
</ul>

<h2 id="il-job-in-background">Il job in background</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/jobs/embed_message_job.rb</span>
<span class="k">class</span> <span class="nc">EmbedMessageJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">message_id</span><span class="p">)</span>
    <span class="n">message</span> <span class="o">=</span> <span class="no">Message</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">message_id</span><span class="p">)</span>
    <span class="k">return</span> <span class="k">unless</span> <span class="n">message</span>

    <span class="n">result</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">embed</span><span class="p">(</span><span class="n">message</span><span class="p">.</span><span class="nf">content</span><span class="p">)</span>
    <span class="n">message</span><span class="p">.</span><span class="nf">update!</span><span class="p">(</span><span class="ss">embedding: </span><span class="n">result</span><span class="p">.</span><span class="nf">vectors</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Message.find_by</code> invece di <code class="language-plaintext highlighter-rouge">Message.find</code> — <code class="language-plaintext highlighter-rouge">find_by</code> restituisce <code class="language-plaintext highlighter-rouge">nil</code> per una riga mancante invece di sollevare un’eccezione, e <code class="language-plaintext highlighter-rouge">return unless message</code> tratta questo come un no-op normale e silenzioso invece che come un errore. Un messaggio potrebbe in linea di principio essere eliminato tra il momento in cui il suo job di embedding viene arruolato e il momento in cui gira davvero; non è un bug per cui valga la pena far crashare un job in background. <code class="language-plaintext highlighter-rouge">result.vectors</code> — l’array di 1.536 float da <code class="language-plaintext highlighter-rouge">RubyLLM::Embedding</code> — viene assegnato direttamente alla colonna <code class="language-plaintext highlighter-rouge">embedding</code>, e <code class="language-plaintext highlighter-rouge">neighbor</code>/pgvector gestiscono il vero formato di salvataggio sotto.</p>

<h2 id="cercare">Cercare</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/search_controller.rb</span>
<span class="k">class</span> <span class="nc">SearchController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">show</span>
    <span class="vi">@query</span> <span class="o">=</span> <span class="n">params</span><span class="p">[</span><span class="ss">:q</span><span class="p">].</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">strip</span>
    <span class="k">return</span> <span class="k">if</span> <span class="vi">@query</span><span class="p">.</span><span class="nf">blank?</span>

    <span class="n">query_embedding</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">embed</span><span class="p">(</span><span class="vi">@query</span><span class="p">).</span><span class="nf">vectors</span>
    <span class="vi">@results</span> <span class="o">=</span> <span class="no">Message</span><span class="p">.</span><span class="nf">where</span><span class="p">.</span><span class="nf">not</span><span class="p">(</span><span class="ss">embedding: </span><span class="kp">nil</span><span class="p">)</span>
                       <span class="p">.</span><span class="nf">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">query_embedding</span><span class="p">,</span> <span class="ss">distance: </span><span class="s2">"cosine"</span><span class="p">)</span>
                       <span class="p">.</span><span class="nf">first</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">params[:q].to_s.strip</code> — <code class="language-plaintext highlighter-rouge">params[:q]</code> è <code class="language-plaintext highlighter-rouge">nil</code> alla prima visita, vuota, della pagina di ricerca (nessuna query ancora inviata); <code class="language-plaintext highlighter-rouge">.to_s</code> lo trasforma in <code class="language-plaintext highlighter-rouge">""</code> così <code class="language-plaintext highlighter-rouge">.strip</code> non deve mai proteggersi da un receiver <code class="language-plaintext highlighter-rouge">nil</code>. <code class="language-plaintext highlighter-rouge">return if @query.blank?</code> si ferma lì, prima di chiamare mai l’API di embedding, se non c’è nulla da cercare.</li>
  <li><code class="language-plaintext highlighter-rouge">RubyLLM.embed(@query).vectors</code> — il testo del box di ricerca viene embeddato attraverso esattamente la stessa chiamata di ogni messaggio. È la parte che rende possibile la ricerca semantica in primo luogo: una domanda è confrontabile con le risposte salvate solo perché entrambe sono state posizionate nello stesso spazio numerico dallo stesso modello.</li>
  <li><code class="language-plaintext highlighter-rouge">Message.where.not(embedding: nil)</code> — i messaggi creati prima di questo episodio (o prima che il proprio job di embedding finisse) hanno un <code class="language-plaintext highlighter-rouge">embedding</code> <code class="language-plaintext highlighter-rouge">nil</code>; escluderli impedisce a <code class="language-plaintext highlighter-rouge">nearest_neighbors</code> di provare mai a calcolare una distanza contro nulla.</li>
  <li><code class="language-plaintext highlighter-rouge">.nearest_neighbors(:embedding, query_embedding, distance: "cosine")</code> — da <code class="language-plaintext highlighter-rouge">neighbor</code>. Ogni riga che questo restituisce ottiene un attributo <code class="language-plaintext highlighter-rouge">neighbor_distance</code> attaccato — un numero che non veniva affatto dalla tabella <code class="language-plaintext highlighter-rouge">messages</code>, calcolato da Postgres come parte della query e riletto sul record in memoria.</li>
  <li><code class="language-plaintext highlighter-rouge">.first(10)</code> — prende i dieci più vicini.</li>
</ul>

<p>Nessuna route per <code class="language-plaintext highlighter-rouge">new</code>/<code class="language-plaintext highlighter-rouge">create</code> e nessun <code class="language-plaintext highlighter-rouge">SearchController#index</code> — l’intera funzionalità è un’unica azione.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resource</span> <span class="ss">:search</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:show</span><span class="p">],</span> <span class="ss">controller: :search</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Prefix Verb URI Pattern       Controller#Action
search GET  /search(.:format) search#show
</code></pre></div></div>

<p>Una <code class="language-plaintext highlighter-rouge">resource</code> singolare invece di <code class="language-plaintext highlighter-rouge">resources</code> — c’è esattamente una “pagina” di ricerca, non una collezione di record di ricerca con URL individuali, quindi la forma singolare (coerente con <code class="language-plaintext highlighter-rouge">resource :chat</code> degli episodi precedenti) è la forma corretta qui, non solo un modo più corto di scrivere la stessa cosa. Un’unica route <code class="language-plaintext highlighter-rouge">GET /search</code> gestisce sia la visita iniziale vuota sia una query inviata, distinte solo dalla presenza o meno di <code class="language-plaintext highlighter-rouge">?q=...</code> — un URL semplice, cliccabile, salvabile nei preferiti, che funziona con il tasto indietro, esattamente ciò che dovrebbe essere una pagina di risultati di ricerca.</p>

<h2 id="la-pagina-di-ricerca">La pagina di ricerca</h2>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/search/show.html.erb %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"max-w-xl mx-auto mt-16 px-4"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"text-2xl font-semibold mb-6"</span><span class="nt">&gt;</span>Search past conversations<span class="nt">&lt;/h1&gt;</span>

  <span class="cp">&lt;%=</span> <span class="n">form_with</span> <span class="ss">url: </span><span class="n">search_path</span><span class="p">,</span> <span class="ss">method: :get</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"flex gap-3 mb-8"</span> <span class="k">do</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;input</span>
      <span class="na">type=</span><span class="s">"text"</span>
      <span class="na">name=</span><span class="s">"q"</span>
      <span class="na">value=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="vi">@query</span> <span class="cp">%&gt;</span><span class="s">"</span>
      <span class="na">placeholder=</span><span class="s">"What are you looking for?"</span>
      <span class="na">class=</span><span class="s">"flex-1 border border-gray-300 rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span>
    <span class="nt">&gt;</span>
    <span class="nt">&lt;button</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">class=</span><span class="s">"bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"</span><span class="nt">&gt;</span>
      Search
    <span class="nt">&lt;/button&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>

  <span class="cp">&lt;%</span> <span class="k">if</span> <span class="vi">@query</span><span class="p">.</span><span class="nf">present?</span> <span class="cp">%&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">if</span> <span class="vi">@results</span><span class="p">.</span><span class="nf">present?</span> <span class="cp">%&gt;</span>
      <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"space-y-4"</span><span class="nt">&gt;</span>
        <span class="cp">&lt;%</span> <span class="vi">@results</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">message</span><span class="o">|</span> <span class="cp">%&gt;</span>
          <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"rounded-md border border-gray-200 p-3"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"flex items-center justify-between mb-1"</span><span class="nt">&gt;</span>
              <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-xs text-gray-500"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">message</span><span class="p">.</span><span class="nf">role</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
              <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-xs text-gray-400"</span><span class="nt">&gt;</span>similarity: <span class="cp">&lt;%=</span> <span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="n">message</span><span class="p">.</span><span class="nf">neighbor_distance</span><span class="p">).</span><span class="nf">round</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
            <span class="nt">&lt;/div&gt;</span>
            <span class="nt">&lt;p&gt;</span><span class="cp">&lt;%=</span> <span class="n">message</span><span class="p">.</span><span class="nf">content</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
          <span class="nt">&lt;/div&gt;</span>
        <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
      <span class="nt">&lt;/div&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">else</span> <span class="cp">%&gt;</span>
      <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-gray-500"</span><span class="nt">&gt;</span>No matching messages yet — nothing has an embedding, or nothing is close enough.<span class="nt">&lt;/p&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">form_with url: search_path, method: :get, ...</code> — <code class="language-plaintext highlighter-rouge">method: :get</code>, non <code class="language-plaintext highlighter-rouge">:post</code> come ogni form precedente in questa serie, e di proposito: una ricerca è una lettura, non un cambio di stato, ed è un <code class="language-plaintext highlighter-rouge">GET</code> che rende l’URL dei risultati (<code class="language-plaintext highlighter-rouge">/search?q=cooking+dinner</code>) qualcosa che puoi copiare, salvare nei preferiti, o raggiungere col tasto indietro.</li>
  <li><code class="language-plaintext highlighter-rouge">value="&lt;%= @query %&gt;"</code> — riempie di nuovo il box con qualunque cosa sia stata cercata, così ricaricare o condividere l’URL mostra la stessa query, non un campo vuoto accanto a risultati popolati.</li>
  <li><code class="language-plaintext highlighter-rouge">1 - message.neighbor_distance</code> — distanza coseno e similarità coseno sono complementari (<code class="language-plaintext highlighter-rouge">distanza = 1 - similarità</code> per l’operatore coseno di pgvector); la distanza grezza dalla query viene qui ribaltata nell’inquadratura più intuitiva “quanto è simile” per la visualizzazione. Non aspettarti però che questo numero si avvicini a <code class="language-plaintext highlighter-rouge">1.0</code> per una corrispondenza forte — continua a leggere.</li>
</ul>

<h2 id="provarlo-e-un-numero-che-sembra-più-basso-di-quanto-sembri">Provarlo, e un numero che sembra più basso di quanto sembri</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Chiedi prima qualche cosa non correlata in chat — una sull’arrostire un pollo, una sulla pasta, una sui polpi — così c’è qualcosa da cercare davvero. Poi visita <code class="language-plaintext highlighter-rouge">/search</code> e prova “cooking dinner”. Il messaggio sul pollo e quello sulla pasta tornano per primi, ordinati correttamente sopra quello sui polpi, anche se “cooking dinner” non compare alla lettera in nessuno di essi.</p>

<p>I numeri di similarità in sé atterrano più bassi di quanto l’intuizione si aspetti — intorno a <code class="language-plaintext highlighter-rouge">0.28</code>–<code class="language-plaintext highlighter-rouge">0.30</code> per una corrispondenza chiaramente rilevante con <code class="language-plaintext highlighter-rouge">text-embedding-3-small</code>, non un <code class="language-plaintext highlighter-rouge">0.9</code> e rotti. Non è un bug o una corrispondenza debole; è solo come è fatto lo spazio vettoriale di questo specifico modello — le similarità coseno tra pezzi di testo <strong>non correlati</strong> con questo modello tendono comunque a stare ben sopra zero, quindi il segnale utile è il <strong>ranking relativo</strong> tra i risultati, non il numero assoluto che ottiene un risultato da solo.</p>

<h2 id="cosa-viene-dopo">Cosa viene dopo</h2>

<p>L’episodio 6 copre il tool calling: far chiamare al modello il codice Ruby dell’app stessa — non solo rispondere da quello che già sa, ma fare davvero qualcosa.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[L’episodio 4 aveva fatto scrivere la risposta da sola, in diretta. Questo episodio fa qualcosa di diverso con la stessa cronologia delle conversazioni: cercarla per significato, non per corrispondenza di parole. Chiedi “cucinare la cena” e ottieni indietro il messaggio in cui l’assistente ha spiegato come arrostire un pollo, anche se la parola “cucinare” non compare mai al suo interno.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="it"><title type="html">VicinoTe: Servizi e categorie</title><link href="https://antoninoscaffidi.github.io/it/vicinote-services-and-categories/" rel="alternate" type="text/html" title="VicinoTe: Servizi e categorie" /><published>2026-08-20T05:00:00+00:00</published><updated>2026-08-20T05:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/vicinote-services-and-categories.it</id><content type="html" xml:base="https://antoninoscaffidi.github.io/vicinote-services-and-categories/"><![CDATA[<p>L’<a href="/it/vicinote-authentication-with-rails-8/">episodio 2</a> aveva fatto funzionare gli account da capo a fondo — registrazione, accesso, disconnessione, reset della password — e si era fermato lì di proposito: nulla toccava ancora <code class="language-plaintext highlighter-rouge">Service</code> o <code class="language-plaintext highlighter-rouge">Booking</code>. Questo episodio è dove il marketplace inizia davvero a essere un marketplace: un utente autenticato può elencare qualcosa che offre, e chiunque può sfogliare cosa è elencato.</p>

<p>È anche dove l’associazione <code class="language-plaintext highlighter-rouge">has_many :services</code> dello schizzo di dominio dell’<a href="/it/vicinote-project-setup-and-domain/">episodio 1</a> viene finalmente scritta nel modello <code class="language-plaintext highlighter-rouge">User</code>. È rimasta in un post del blog come decisione di design per due episodi; oggi diventa codice vero.</p>

<p>Questo è un episodio lungo di proposito — l’obiettivo è che nulla nel diff resti inspiegato: ogni file generato, ogni riga aggiunta a mano, ogni opzione passata a ogni metodo.</p>

<p>Il codice è taggato <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial/tree/episode-3"><code class="language-plaintext highlighter-rouge">episode-3</code></a> nel repo <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial">vicinote-tutorial</a>.</p>

<h2 id="cosa-tocca-questo-episodio-in-sintesi">Cosa tocca questo episodio, in sintesi</h2>

<p>Prima di andare file per file, ecco la mappa. Due nuovi modelli, un modello modificato, un nuovo controller, due nuove view, una modifica alle routes, un file di seed, e una correzione di traduzione di una riga:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>db/migrate/..._create_categories.rb   nuovo — la tabella categories
db/migrate/..._create_services.rb     nuovo — la tabella services
app/models/category.rb                nuovo — Category: has_many :services, validates :name
app/models/service.rb                 nuovo — Service: belongs_to :user/:category, validazioni, accessor price
app/models/user.rb                    modifica — aggiunge has_many :services
app/controllers/services_controller.rb nuovo — index (pubblico), new, create (solo autenticati)
app/views/services/index.html.erb     nuovo — la pagina di elenco
app/views/services/new.html.erb       nuovo — il form "offri un servizio"
config/routes.rb                      modifica — resources :services, only: [:index, :new, :create]
config/locales/en.yml                 modifica — corregge un nome di attributo interno trapelato nei messaggi d'errore, imposta l'euro come valuta di default
db/seeds.rb                           modifica — la lista fissa di categorie
app/views/pages/home.html.erb         modifica — i due bottoni placeholder ora portano da qualche parte
</code></pre></div></div>

<h2 id="generare-i-due-modelli">Generare i due modelli</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate model Category <span class="s2">"name:string:uniq"</span>
bin/rails generate model Service title:string description:text price_cents:integer user:references category:references
</code></pre></div></div>

<p>Ogni coppia <code class="language-plaintext highlighter-rouge">campo:tipo</code> dopo il nome del modello diventa una colonna della migrazione. La sintassi ha qualche trucco in più che vale la pena spiegare, dato che entrambi i comandi li usano:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">name:string:uniq</code> — il terzo segmento, <code class="language-plaintext highlighter-rouge">:uniq</code>, non è un tipo. Dice al generator di aggiungere anche un indice univoco su quella colonna, così scrive una chiamata <code class="language-plaintext highlighter-rouge">add_index</code> per noi invece di doverci ricordare di aggiungerne una più tardi.</li>
  <li><code class="language-plaintext highlighter-rouge">user:references</code> e <code class="language-plaintext highlighter-rouge">category:references</code> — <code class="language-plaintext highlighter-rouge">references</code> non è nemmeno un tipo di colonna. È una scorciatoia del generator che significa “questo modello appartiene a quello.” Si espande in una chiamata <code class="language-plaintext highlighter-rouge">t.references</code> nella migrazione (una colonna integer di foreign key più un indice), <strong>e</strong> fa sì che il generator scriva direttamente una riga <code class="language-plaintext highlighter-rouge">belongs_to :user</code> / <code class="language-plaintext highlighter-rouge">belongs_to :category</code> nel file del modello generato. Per questo il modello <code class="language-plaintext highlighter-rouge">Service</code> ha già entrambe le associazioni dentro nel momento in cui il generator finisce — non abbiamo scritto <code class="language-plaintext highlighter-rouge">belongs_to</code> a mano noi.</li>
</ul>

<p>Ogni comando stampa cosa ha creato:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>      create    db/migrate/20260820055929_create_categories.rb
      create    app/models/category.rb
      invoke    test_unit
      create      test/models/category_test.rb
      create      test/fixtures/categories.yml
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>      create    db/migrate/20260820055946_create_services.rb
      create    app/models/service.rb
      invoke    test_unit
      create      test/models/service_test.rb
      create      test/fixtures/services.yml
</code></pre></div></div>

<p>I file <code class="language-plaintext highlighter-rouge">test_unit</code> sono l’impalcatura di test predefinita di Rails (una classe di test vuota e un file di fixture vuoto) — questa serie non li usa, quindi restano come generati e non vengono discussi oltre.</p>

<h2 id="la-migrazione-categories-riga-per-riga">La migrazione categories, riga per riga</h2>

<p>Generata, poi modificata a mano per aggiungere una parola:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_categories.rb</span>
<span class="k">class</span> <span class="nc">CreateCategories</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">8.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">create_table</span> <span class="ss">:categories</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>

      <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
    <span class="k">end</span>
    <span class="n">add_index</span> <span class="ss">:categories</span><span class="p">,</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">unique: </span><span class="kp">true</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">class CreateCategories &lt; ActiveRecord::Migration[8.1]</code> — ogni migrazione è una sottoclasse di <code class="language-plaintext highlighter-rouge">ActiveRecord::Migration</code>, versionata sulla release di Rails che l’ha generata (<code class="language-plaintext highlighter-rouge">[8.1]</code>). Quella versione fissata è ciò che permette a Rails di cambiare il comportamento del DSL delle migrazioni tra versioni major senza rompere silenziosamente le migrazioni scritte per una più vecchia.</li>
  <li><code class="language-plaintext highlighter-rouge">def change</code> — l’unico metodo di cui Rails ha bisogno. Per operazioni che può invertire automaticamente (creare una tabella, aggiungere una colonna, aggiungere un indice), <code class="language-plaintext highlighter-rouge">change</code> basta; Rails deduce come annullarla se mai fai rollback. Le operazioni irreversibili avrebbero bisogno di metodi <code class="language-plaintext highlighter-rouge">up</code>/<code class="language-plaintext highlighter-rouge">down</code> separati — qui non serve nulla del genere.</li>
  <li><code class="language-plaintext highlighter-rouge">create_table :categories do |t|</code> — apre la definizione della tabella; <code class="language-plaintext highlighter-rouge">t</code> è l’oggetto su cui viene definita ogni colonna.</li>
  <li><code class="language-plaintext highlighter-rouge">t.string :name, null: false</code> — una colonna <code class="language-plaintext highlighter-rouge">VARCHAR</code>. Il generator aveva scritto <code class="language-plaintext highlighter-rouge">t.string :name</code>; il <code class="language-plaintext highlighter-rouge">null: false</code> è l’unica parola che abbiamo aggiunto a mano, con lo stesso rigore che l’episodio 2 aveva messo nella tabella <code class="language-plaintext highlighter-rouge">users</code>. Senza, Postgres salverebbe volentieri una categoria senza alcun nome, e non è uno stato che l’app ha motivo di usare.</li>
  <li><code class="language-plaintext highlighter-rouge">t.timestamps</code> — scorciatoia per due colonne, <code class="language-plaintext highlighter-rouge">created_at</code> e <code class="language-plaintext highlighter-rouge">updated_at</code>, entrambe <code class="language-plaintext highlighter-rouge">datetime</code>, entrambe riempite automaticamente da ActiveRecord alla creazione/modifica. Quasi ogni tabella di questa serie ha questa riga; <code class="language-plaintext highlighter-rouge">Category</code> non fa eccezione.</li>
  <li><code class="language-plaintext highlighter-rouge">add_index :categories, :name, unique: true</code> — questo è ciò che <code class="language-plaintext highlighter-rouge">:uniq</code> nel comando del generator ha prodotto. È una garanzia di unicità a <strong>livello di database</strong>, imposta da Postgres stesso, non solo da una validazione Rails che in teoria potrebbe essere aggirata da due richieste simultanee in corsa tra loro. <code class="language-plaintext highlighter-rouge">Category</code> valida l’unicità anche nel modello (sotto) — cintura e bretelle: la validazione del modello dà un errore amichevole nel caso normale, l’indice garantisce la correttezza anche in caso di corsa critica.</li>
</ul>

<h2 id="la-migrazione-services-riga-per-riga">La migrazione services, riga per riga</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_services.rb</span>
<span class="k">class</span> <span class="nc">CreateServices</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">8.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">create_table</span> <span class="ss">:services</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">text</span> <span class="ss">:description</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">integer</span> <span class="ss">:price_cents</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:category</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>

      <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">t.string :title, null: false</code> — stessa forma di <code class="language-plaintext highlighter-rouge">Category#name</code>: un campo di testo breve obbligatorio.</li>
  <li><code class="language-plaintext highlighter-rouge">t.text :description, null: false</code> — <code class="language-plaintext highlighter-rouge">text</code> invece di <code class="language-plaintext highlighter-rouge">string</code>. In Postgres è quasi solo una formalità (entrambi mappano allo stesso tipo <code class="language-plaintext highlighter-rouge">text</code> illimitato sotto il cofano; la distinzione <code class="language-plaintext highlighter-rouge">string</code> vs <code class="language-plaintext highlighter-rouge">text</code> di Rails è soprattutto un suggerimento a livello Rails, non una differenza di storage in Postgres), ma segnala un’intenzione: questa colonna contiene un paragrafo, non un’etichetta, e alcuni helper del form (<code class="language-plaintext highlighter-rouge">form.text_area</code> invece di <code class="language-plaintext highlighter-rouge">form.text_field</code>) si basano esattamente su questo tipo più avanti.</li>
  <li><code class="language-plaintext highlighter-rouge">t.integer :price_cents, null: false</code> — un semplice intero. C’è un’intera sezione più sotto sul perché è un intero e non un <code class="language-plaintext highlighter-rouge">decimal</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">t.references :user, null: false, foreign_key: true</code> — questa riga è ciò che <code class="language-plaintext highlighter-rouge">user:references</code> sulla riga di comando ha generato, e fa tre cose insieme:
    <ol>
      <li>Aggiunge una colonna integer chiamata <code class="language-plaintext highlighter-rouge">user_id</code> (il suffisso <code class="language-plaintext highlighter-rouge">_id</code> e la trasformazione da plurale a singolare sono entrambe convenzioni Rails, non qualcosa che abbiamo scritto noi).</li>
      <li><code class="language-plaintext highlighter-rouge">foreign_key: true</code> — aggiunge un vero e proprio vincolo di foreign key Postgres da <code class="language-plaintext highlighter-rouge">services.user_id</code> a <code class="language-plaintext highlighter-rouge">users.id</code>. Il database stesso ora rifiuterà di inserire una riga <code class="language-plaintext highlighter-rouge">Service</code> il cui <code class="language-plaintext highlighter-rouge">user_id</code> non corrisponde a un utente reale, e rifiuterà di eliminare una riga <code class="language-plaintext highlighter-rouge">User</code> che ha ancora servizi che puntano a essa (a meno che l’associazione non dica diversamente — altro su questo più sotto).</li>
      <li>Un indice su <code class="language-plaintext highlighter-rouge">user_id</code>, aggiunto automaticamente, perché una colonna di foreign key non indicizzata rende lenta ogni query che fa join attraverso di essa man mano che la tabella cresce. Questo non era opzionale né qualcosa che abbiamo chiesto — <code class="language-plaintext highlighter-rouge">t.references</code> indicizza sempre.
<code class="language-plaintext highlighter-rouge">null: false</code> qui significa che ogni servizio <strong>deve</strong> appartenere a qualcuno; non esiste un servizio senza un fornitore.</li>
    </ol>
  </li>
  <li><code class="language-plaintext highlighter-rouge">t.references :category, null: false, foreign_key: true</code> — forma identica, per l’altro lato della relazione.</li>
</ul>

<p>Nota che nessuna delle due migrazioni menziona <code class="language-plaintext highlighter-rouge">belongs_to</code> o <code class="language-plaintext highlighter-rouge">has_many</code> — le migrazioni descrivono solo lo <strong>schema del database</strong> (tabelle, colonne, vincoli, indici). Le associazioni sono un concetto separato, a livello Ruby, dichiarato nei modelli, che è la prossima sezione.</p>

<h2 id="eseguire-le-migrazioni">Eseguire le migrazioni</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:migrate
</code></pre></div></div>

<p>Questo esegue entrambe le migrazioni in sospeso in ordine di timestamp (categories, poi services — la foreign key di services verso categories ha bisogno che la tabella categories esista già) e riscrive <code class="language-plaintext highlighter-rouge">db/schema.rb</code> per riflettere il nuovo stato. <code class="language-plaintext highlighter-rouge">schema.rb</code> non è qualcosa da modificare a mano; è l’istantanea in cache di Rails di “com’è fatto il database adesso”, rigenerata ogni volta che fai una migrazione, ed è ciò che legge il <code class="language-plaintext highlighter-rouge">bin/rails db:setup</code> di un collega per costruire un database nuovo che corrisponda al tuo senza rieseguire ogni migrazione mai scritta.</p>

<h2 id="i-modelli-generati-prima-di-qualsiasi-modifica">I modelli generati, prima di qualsiasi modifica</h2>

<p>Subito dopo che il generator ha girato, prima di toccare qualsiasi cosa:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/category.rb</span>
<span class="k">class</span> <span class="nc">Category</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/service.rb</span>
<span class="k">class</span> <span class="nc">Service</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:user</span>
  <span class="n">belongs_to</span> <span class="ss">:category</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Category</code> è vuota perché non ha colonne <code class="language-plaintext highlighter-rouge">references</code> — non c’era nulla da cui il generator potesse dedurre un’associazione. <code class="language-plaintext highlighter-rouge">Service</code> ha già entrambe le righe <code class="language-plaintext highlighter-rouge">belongs_to</code> per via dei campi <code class="language-plaintext highlighter-rouge">:references</code> passati sulla riga di comando, come spiegato sopra. Entrambe ereditano da <code class="language-plaintext highlighter-rouge">ApplicationRecord</code>, la classe base astratta che ogni modello in un’app Rails condivide (essa stessa una sottile sottoclasse di <code class="language-plaintext highlighter-rouge">ActiveRecord::Base</code>), che è ciò che dà loro <code class="language-plaintext highlighter-rouge">.find</code>, <code class="language-plaintext highlighter-rouge">.create</code>, <code class="language-plaintext highlighter-rouge">.where</code>, le validazioni, e tutto il resto che ActiveRecord fornisce — niente di tutto ciò è scritto in nessuno dei due file, viene ereditato.</p>

<p>Una cosa che vale la pena nominare esplicitamente: da Rails 5 in poi, <code class="language-plaintext highlighter-rouge">belongs_to</code> è <strong>obbligatorio per default</strong>. Scrivere <code class="language-plaintext highlighter-rouge">belongs_to :user</code> non dichiara solo l’associazione, aggiunge implicitamente anche una validazione di presenza — un <code class="language-plaintext highlighter-rouge">Service</code> senza <code class="language-plaintext highlighter-rouge">user</code> fallisce la validazione, oltre al fatto che il database lo rifiuta già via <code class="language-plaintext highlighter-rouge">null: false, foreign_key: true</code> dalla migrazione. Due livelli indipendenti che impongono la stessa regola, a due livelli diversi (validazione Ruby vs vincolo SQL), che è esattamente il messaggio di errore “Category must exist” che vedrai più avanti in questo post — quella frase è il testo di default di Rails per un controllo di presenza fallito su <code class="language-plaintext highlighter-rouge">category</code> da un <code class="language-plaintext highlighter-rouge">belongs_to</code>.</p>

<h2 id="category-completato">Category, completato</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/category.rb</span>
<span class="k">class</span> <span class="nc">Category</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_many</span> <span class="ss">:services</span>

  <span class="n">validates</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">uniqueness: </span><span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">has_many :services</code> — l’altra metà di <code class="language-plaintext highlighter-rouge">Service belongs_to :category</code>. Le associazioni Rails si dichiarano su entrambi i lati a mano; non c’è modo di dichiarare solo un lato e far dedurre l’altro. Questo è ciò che fa funzionare <code class="language-plaintext highlighter-rouge">some_category.services</code> — l’associazione cerca ogni riga <code class="language-plaintext highlighter-rouge">Service</code> il cui <code class="language-plaintext highlighter-rouge">category_id</code> corrisponde, tramite la foreign key che la migrazione ha creato.</li>
  <li><code class="language-plaintext highlighter-rouge">validates :name, presence: true, uniqueness: true</code> — <code class="language-plaintext highlighter-rouge">presence: true</code> duplica ciò che il <code class="language-plaintext highlighter-rouge">null: false</code> nella migrazione già garantisce a livello di database, ma per un pubblico diverso: un vincolo di database fallito solleva una brutta eccezione <code class="language-plaintext highlighter-rouge">ActiveRecord::NotNullViolation</code>, mentre una validazione di presenza fallita dà a <code class="language-plaintext highlighter-rouge">@category.errors</code> un messaggio amichevole che una view può renderizzare. <code class="language-plaintext highlighter-rouge">uniqueness: true</code> è la metà a livello di modello della coppia cintura-e-bretelle con l’indice unico della migrazione — questa esegue una <code class="language-plaintext highlighter-rouge">SELECT</code> prima di salvare e dà un errore pulito; l’indice è ciò che impedisce davvero a un duplicato di raggiungere mai la tabella se due richieste sono in corsa critica oltre la validazione nello stesso istante.</li>
</ul>

<h2 id="service-completato">Service, completato</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/service.rb</span>
<span class="k">class</span> <span class="nc">Service</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:user</span>
  <span class="n">belongs_to</span> <span class="ss">:category</span>

  <span class="n">validates</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>
  <span class="n">validates</span> <span class="ss">:description</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>
  <span class="n">validates</span> <span class="ss">:price_cents</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">numericality: </span><span class="p">{</span> <span class="ss">greater_than: </span><span class="mi">0</span><span class="p">,</span> <span class="ss">only_integer: </span><span class="kp">true</span> <span class="p">}</span>

  <span class="c1"># price_cents è ciò che viene salvato e confrontato (niente sorprese di</span>
  <span class="c1"># arrotondamento con i float), ma nessuno vuole scrivere i centesimi in</span>
  <span class="c1"># un form. Questo parla in euro in entrata e in uscita, così il campo</span>
  <span class="c1"># del form può semplicemente essere "price".</span>
  <span class="k">def</span> <span class="nf">price</span>
    <span class="n">price_cents</span> <span class="o">&amp;&amp;</span> <span class="n">price_cents</span> <span class="o">/</span> <span class="mf">100.0</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">price</span><span class="o">=</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
    <span class="nb">self</span><span class="p">.</span><span class="nf">price_cents</span> <span class="o">=</span> <span class="n">value</span><span class="p">.</span><span class="nf">present?</span> <span class="p">?</span> <span class="p">(</span><span class="n">value</span><span class="p">.</span><span class="nf">to_f</span> <span class="o">*</span> <span class="mi">100</span><span class="p">).</span><span class="nf">round</span> <span class="p">:</span> <span class="kp">nil</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">validates :title, presence: true</code> / <code class="language-plaintext highlighter-rouge">validates :description, presence: true</code> — stesso ragionamento di <code class="language-plaintext highlighter-rouge">Category#name</code>: il <code class="language-plaintext highlighter-rouge">null: false</code> della migrazione è l’ultima linea di difesa, questa è quella amichevole che gira per prima.</li>
  <li><code class="language-plaintext highlighter-rouge">validates :price_cents, presence: true, numericality: { greater_than: 0, only_integer: true }</code> — tre controlli in un’unica riga. <code class="language-plaintext highlighter-rouge">presence: true</code> rifiuta <code class="language-plaintext highlighter-rouge">nil</code>. <code class="language-plaintext highlighter-rouge">numericality:</code> da sola rifiuterebbe solo qualcosa che non è affatto un numero (una stringa come <code class="language-plaintext highlighter-rouge">"abc"</code>); l’opzione <code class="language-plaintext highlighter-rouge">greater_than: 0</code> aggiunge la regola di business che un servizio gratuito non è qualcosa che questa validazione permette, e <code class="language-plaintext highlighter-rouge">only_integer: true</code> rifiuta centesimi frazionari (<code class="language-plaintext highlighter-rouge">4250.5</code>), cosa che non dovrebbe comunque essere raggiungibile dato che <code class="language-plaintext highlighter-rouge">price_cents</code> è una colonna database <code class="language-plaintext highlighter-rouge">integer</code>, ma la validazione rende la regola esplicita invece di affidarsi solo al tipo di colonna per imporla prima che il valore raggiunga il database.</li>
  <li><code class="language-plaintext highlighter-rouge">def price</code> / <code class="language-plaintext highlighter-rouge">def price=</code> — trattati per intero nella prossima sezione; è il pezzo che permette a un form di parlare in euro mentre la colonna sotto resta in centesimi.</li>
</ul>

<h2 id="una-decisione-su-cui-vale-la-pena-soffermarsi-price_cents-non-price">Una decisione su cui vale la pena soffermarsi: price_cents, non price</h2>

<p>Il generator ha scritto <code class="language-plaintext highlighter-rouge">price_cents:integer</code>, non <code class="language-plaintext highlighter-rouge">price:decimal</code>, sulla riga di comando — quella formulazione è stata scelta deliberatamente in partenza, ed è la stessa categoria di decisione che l’episodio 1 aveva preso su <code class="language-plaintext highlighter-rouge">Booking</code> che salva il proprio prezzo invece di leggere <code class="language-plaintext highlighter-rouge">service.price</code> dal vivo: il denaro gestito come float, o persino come <code class="language-plaintext highlighter-rouge">decimal</code> ingenuo, prima o poi produce un errore di arrotondamento che si presenta come qualche centesimo di scarto su una fattura, nel momento peggiore possibile. Salvare l’importo come numero intero di centesimi evita del tutto questa categoria di bug — non c’è una parte frazionaria da arrotondare, perché non c’è nessuna frazione. 42,50 € viene salvato come l’intero <code class="language-plaintext highlighter-rouge">4250</code>, punto. Anche chiamarla <code class="language-plaintext highlighter-rouge">price_cents</code> invece di, per esempio, <code class="language-plaintext highlighter-rouge">price_usd_cents</code> è deliberato — “cents” è l’unità minore dell’euro tanto quanto lo è del dollaro, quindi nulla nella colonna stessa è legato a una valuta particolare; solo il livello di formattazione, trattato più avanti in questo post, sa quale valuta è in uso.</p>

<p>Il costo è che nessuno vuole scrivere “4250” in un form intendendo 42,50 €. Quindi <code class="language-plaintext highlighter-rouge">Service</code> ottiene un piccolo accessor virtuale — due normali metodi Ruby, non una colonna del database — che traduce gli euro in centesimi e viceversa:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">price</span>
  <span class="n">price_cents</span> <span class="o">&amp;&amp;</span> <span class="n">price_cents</span> <span class="o">/</span> <span class="mf">100.0</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">price</span><span class="o">=</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
  <span class="nb">self</span><span class="p">.</span><span class="nf">price_cents</span> <span class="o">=</span> <span class="n">value</span><span class="p">.</span><span class="nf">present?</span> <span class="p">?</span> <span class="p">(</span><span class="n">value</span><span class="p">.</span><span class="nf">to_f</span> <span class="o">*</span> <span class="mi">100</span><span class="p">).</span><span class="nf">round</span> <span class="p">:</span> <span class="kp">nil</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Passando in rassegna entrambi:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">def price</code> — legge per la visualizzazione. <code class="language-plaintext highlighter-rouge">price_cents &amp;&amp; price_cents / 100.0</code> usa il <code class="language-plaintext highlighter-rouge">&amp;&amp;</code> di Ruby per il suo comportamento di short-circuit, non come controllo booleano: se <code class="language-plaintext highlighter-rouge">price_cents</code> è <code class="language-plaintext highlighter-rouge">nil</code> (un <code class="language-plaintext highlighter-rouge">Service</code> nuovo di zecca, non ancora salvato), l’intera espressione va in short-circuit a <code class="language-plaintext highlighter-rouge">nil</code> senza tentare <code class="language-plaintext highlighter-rouge">nil / 100.0</code>, che solleverebbe un’eccezione. Se è un intero vero, <code class="language-plaintext highlighter-rouge">&amp;&amp;</code> valuta e restituisce il lato destro — la divisione. Dividere per <code class="language-plaintext highlighter-rouge">100.0</code> (un literal float, non <code class="language-plaintext highlighter-rouge">100</code>) forza Ruby a fare una divisione in virgola mobile invece che una divisione intera, così <code class="language-plaintext highlighter-rouge">4250 / 100.0</code> dà <code class="language-plaintext highlighter-rouge">42.5</code>, non <code class="language-plaintext highlighter-rouge">42</code> troncato.</li>
  <li><code class="language-plaintext highlighter-rouge">def price=(value)</code> — il setter, chiamato automaticamente ogni volta che qualcosa fa <code class="language-plaintext highlighter-rouge">service.price = "42.50"</code> o, altrettanto automaticamente, ogni volta che un form invia un campo chiamato <code class="language-plaintext highlighter-rouge">price</code> tramite mass assignment (<code class="language-plaintext highlighter-rouge">Service.new(price: "42.50", ...)</code>) — Rails chiama il metodo setter per ogni attributo permesso, non gli importa se quel metodo corrisponde a una colonna reale o no. <code class="language-plaintext highlighter-rouge">value.present?</code> protegge da input vuoto (una stringa vuota da un campo del form svuotato) invece di tentare di convertire <code class="language-plaintext highlighter-rouge">""</code> in un numero. Quando c’è davvero un valore, <code class="language-plaintext highlighter-rouge">value.to_f * 100</code> converte in euro-come-float e moltiplica per 100 per ottenere i centesimi, e <code class="language-plaintext highlighter-rouge">.round</code> lo riporta a un numero intero — <code class="language-plaintext highlighter-rouge">.to_f</code> su input utente può produrre cose come <code class="language-plaintext highlighter-rouge">42.499999999999996</code> per la normale imprecisione della virgola mobile, e <code class="language-plaintext highlighter-rouge">.round</code> è ciò che ripulisce tutto questo a esattamente <code class="language-plaintext highlighter-rouge">4250</code> prima che raggiunga mai <code class="language-plaintext highlighter-rouge">price_cents=</code>.</li>
</ul>

<p>Poiché <code class="language-plaintext highlighter-rouge">price=</code> è un normale metodo Ruby e non un attributo sostenuto da ActiveRecord, gira immediatamente quando gli attributi vengono assegnati — prima di <code class="language-plaintext highlighter-rouge">save</code>, prima di qualsiasi validazione. Nel momento in cui <code class="language-plaintext highlighter-rouge">validates :price_cents, ...</code> gira, <code class="language-plaintext highlighter-rouge">price_cents</code> è già stato popolato da questo setter. Il campo del form discusso più avanti in questo post è solo <code class="language-plaintext highlighter-rouge">form.text_field :price</code> — la view non ha mai idea che esistano i centesimi.</p>

<h2 id="le-categorie-sono-seminate-non-create">Le categorie sono seminate, non create</h2>

<p>Un marketplace dove chiunque può inventare una nuova categoria finisce con cinquanta categorie che significano la stessa cosa, scritte in cinque modi diversi, e sfogliare per categoria smette di essere utile. VicinoTe cura invece una lista fissa, in <code class="language-plaintext highlighter-rouge">db/seeds.rb</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/seeds.rb</span>
<span class="p">[</span>
  <span class="s2">"Home Repair"</span><span class="p">,</span>
  <span class="s2">"Tutoring"</span><span class="p">,</span>
  <span class="s2">"Cleaning"</span><span class="p">,</span>
  <span class="s2">"Gardening"</span><span class="p">,</span>
  <span class="s2">"Pet Care"</span><span class="p">,</span>
  <span class="s2">"Moving Help"</span><span class="p">,</span>
  <span class="s2">"Tech Support"</span><span class="p">,</span>
  <span class="s2">"Beauty &amp; Wellness"</span>
<span class="p">].</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="nb">name</span><span class="o">|</span>
  <span class="no">Category</span><span class="p">.</span><span class="nf">find_or_create_by!</span><span class="p">(</span><span class="ss">name: </span><span class="nb">name</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li>L’array letterale di otto stringhe <strong>è</strong> la lista delle categorie, in puro Ruby, proprio lì nel file di seed — nessuna interfaccia di amministrazione, nessun formato di configurazione separato, solo un array sotto controllo di versione che un episodio futuro potrebbe estendere aggiungendo una nona stringa.</li>
  <li><code class="language-plaintext highlighter-rouge">.each do |name| ... end</code> itera l’array una volta per categoria.</li>
  <li><code class="language-plaintext highlighter-rouge">Category.find_or_create_by!(name: name)</code> — questo singolo metodo fa due lavori a seconda di cosa trova: se esiste già una <code class="language-plaintext highlighter-rouge">Category</code> con quel <code class="language-plaintext highlighter-rouge">name</code>, la restituisce intatta; se no, ne costruisce e ne salva una nuova. Il <code class="language-plaintext highlighter-rouge">!</code> finale significa che solleva <code class="language-plaintext highlighter-rouge">ActiveRecord::RecordInvalid</code> in caso di validazione fallita invece di restituire silenziosamente un record non salvato e non valido — cosa che conta qui, perché un file di seed che fallisce rumorosamente è molto più facile da debuggare di uno che fallisce silenziosamente e lascia il database seminato a metà.</li>
</ul>

<p>Quella combinazione — un array fisso più <code class="language-plaintext highlighter-rouge">find_or_create_by!</code> — è ciò che rende il file sicuro da eseguire più di una volta. Rieseguire <code class="language-plaintext highlighter-rouge">db:seed</code> dopo aver aggiunto una nona categoria all’array più avanti crea solo quella nuova; le prime otto vengono trovate per nome e lasciate intatte, non duplicate.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:migrate
bin/rails db:seed
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">db:seed</code> esegue semplicemente <code class="language-plaintext highlighter-rouge">db/seeds.rb</code> come un normale script Ruby dentro l’environment dell’app — niente di più misterioso di così.</p>

<h2 id="scrivere-lassociazione-che-lepisodio-1-aveva-solo-schizzato">Scrivere l’associazione che l’episodio 1 aveva solo schizzato</h2>

<p>Il design del dominio dell’episodio 1 mostrava questo codice come illustrazione della decisione “il ruolo emerge dall’associazione” — l’intero punto era che uno <code class="language-plaintext highlighter-rouge">User</code> non è etichettato con una colonna <code class="language-plaintext highlighter-rouge">role: "provider"</code>, è un fornitore <strong>perché</strong> ha dei servizi. Non era mai stato davvero nel modello <code class="language-plaintext highlighter-rouge">User</code>, però; l’episodio 2 riguardava l’autenticazione e non l’ha toccato. Ci va ora:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/user.rb</span>
<span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_secure_password</span>
  <span class="n">has_many</span> <span class="ss">:sessions</span><span class="p">,</span> <span class="ss">dependent: :destroy</span>
  <span class="n">has_many</span> <span class="ss">:services</span><span class="p">,</span> <span class="ss">dependent: :destroy</span>

  <span class="n">normalizes</span> <span class="ss">:email_address</span><span class="p">,</span> <span class="ss">with: </span><span class="o">-&gt;</span><span class="p">(</span><span class="n">e</span><span class="p">)</span> <span class="p">{</span> <span class="n">e</span><span class="p">.</span><span class="nf">strip</span><span class="p">.</span><span class="nf">downcase</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>È cambiata solo una riga — <code class="language-plaintext highlighter-rouge">has_many :services, dependent: :destroy</code> è stata aggiunta, tutto il resto (<code class="language-plaintext highlighter-rouge">has_secure_password</code>, l’associazione <code class="language-plaintext highlighter-rouge">sessions</code>, la normalizzazione dell’email) è invariato dall’episodio 2.</p>

<p><code class="language-plaintext highlighter-rouge">dependent: :destroy</code> corrisponde a quello che <code class="language-plaintext highlighter-rouge">sessions</code> fa già nella riga sopra, per lo stesso motivo: senza, eliminare uno <code class="language-plaintext highlighter-rouge">User</code> fallirebbe direttamente (il vincolo <code class="language-plaintext highlighter-rouge">foreign_key: true</code> del database dalla migrazione rifiuterebbe l’eliminazione, dato che le righe <code class="language-plaintext highlighter-rouge">services</code> punterebbero ancora a uno <code class="language-plaintext highlighter-rouge">user_id</code> che sta per smettere di esistere) oppure, se il vincolo fosse allentato, lascerebbe righe <code class="language-plaintext highlighter-rouge">Service</code> orfane nella tabella per sempre, che puntano a nessuno. <code class="language-plaintext highlighter-rouge">dependent: :destroy</code> dice a Rails di eliminare prima ogni <code class="language-plaintext highlighter-rouge">Service</code> associato, automaticamente, ogni volta che uno <code class="language-plaintext highlighter-rouge">User</code> viene distrutto — la pulizia è una parola, non qualcosa che ogni futuro punto di chiamata deve ricordarsi di fare a mano.</p>

<h2 id="il-controller-index-pubblico-tutto-il-resto-protetto">Il controller: index pubblico, tutto il resto protetto</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/services_controller.rb</span>
<span class="k">class</span> <span class="nc">ServicesController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">allow_unauthenticated_access</span> <span class="ss">only: :index</span>

  <span class="k">def</span> <span class="nf">index</span>
    <span class="vi">@services</span> <span class="o">=</span> <span class="no">Service</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="ss">:category</span><span class="p">,</span> <span class="ss">:user</span><span class="p">).</span><span class="nf">order</span><span class="p">(</span><span class="ss">created_at: :desc</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@service</span> <span class="o">=</span> <span class="no">Current</span><span class="p">.</span><span class="nf">user</span><span class="p">.</span><span class="nf">services</span><span class="p">.</span><span class="nf">new</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@service</span> <span class="o">=</span> <span class="no">Current</span><span class="p">.</span><span class="nf">user</span><span class="p">.</span><span class="nf">services</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">service_params</span><span class="p">)</span>

    <span class="k">if</span> <span class="vi">@service</span><span class="p">.</span><span class="nf">save</span>
      <span class="n">redirect_to</span> <span class="n">services_path</span><span class="p">,</span> <span class="ss">notice: </span><span class="s2">"Your service is live."</span>
    <span class="k">else</span>
      <span class="n">render</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">status: :unprocessable_entity</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">service_params</span>
    <span class="n">params</span><span class="p">.</span><span class="nf">require</span><span class="p">(</span><span class="ss">:service</span><span class="p">).</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:title</span><span class="p">,</span> <span class="ss">:description</span><span class="p">,</span> <span class="ss">:price</span><span class="p">,</span> <span class="ss">:category_id</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Riga per riga:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">class ServicesController &lt; ApplicationController</code> — ogni controller in un’app Rails eredita da <code class="language-plaintext highlighter-rouge">ApplicationController</code>, che è dove è incluso il concern <code class="language-plaintext highlighter-rouge">Authentication</code> dell’episodio 2. È questo che rende significativa la riga successiva.</li>
  <li><code class="language-plaintext highlighter-rouge">allow_unauthenticated_access only: :index</code> — il concern <code class="language-plaintext highlighter-rouge">Authentication</code> dell’episodio 2 esegue <code class="language-plaintext highlighter-rouge">before_action :require_authentication</code> per ogni azione su ogni controller di default, il che significa che <strong>senza questa riga</strong>, un visitatore anonimo che raggiunge una qualsiasi azione qui verrebbe reindirizzato dritto alla pagina di accesso. È corretto per <code class="language-plaintext highlighter-rouge">new</code> e <code class="language-plaintext highlighter-rouge">create</code> — nessuno dovrebbe poter elencare un servizio in anonimo — ma sbagliato per <code class="language-plaintext highlighter-rouge">index</code>: sfogliare il marketplace deve funzionare anche per chi non si è ancora registrato, altrimenti non c’è motivo di registrarsi. <code class="language-plaintext highlighter-rouge">only: :index</code> esclude di nuovo quella singola azione dal requisito lasciando <code class="language-plaintext highlighter-rouge">new</code> e <code class="language-plaintext highlighter-rouge">create</code> protette. È esattamente lo stesso meccanismo che l’episodio 2 aveva usato, senza alcun argomento (<code class="language-plaintext highlighter-rouge">allow_unauthenticated_access</code>, senza <code class="language-plaintext highlighter-rouge">only:</code>), per rendere pubblico l’intero <code class="language-plaintext highlighter-rouge">PagesController</code> — qui vogliamo esentare solo una delle tre azioni, quindi <code class="language-plaintext highlighter-rouge">only:</code> restringe.</li>
  <li><code class="language-plaintext highlighter-rouge">def index</code> / <code class="language-plaintext highlighter-rouge">@services = Service.includes(:category, :user).order(created_at: :desc)</code> — <code class="language-plaintext highlighter-rouge">Service.includes(:category, :user)</code> non sta filtrando nulla; <code class="language-plaintext highlighter-rouge">includes</code> è il metodo di eager-loading di ActiveRecord. Senza, una view che scorre <code class="language-plaintext highlighter-rouge">@services</code> e chiama <code class="language-plaintext highlighter-rouge">service.category.name</code> e <code class="language-plaintext highlighter-rouge">service.user.email_address</code> per ognuno farebbe scattare una <code class="language-plaintext highlighter-rouge">SELECT</code> aggiuntiva per ogni servizio, per ogni associazione — il classico problema delle query N+1, dove mostrare 50 servizi significa silenziosamente 101 query (1 per la lista, più 50 per le categorie, più 50 per gli utenti) invece di 3. <code class="language-plaintext highlighter-rouge">includes</code> carica le categorie e gli utenti di tutti i servizi in anticipo, in un piccolo numero fisso di query aggiuntive, indipendentemente da quanti servizi ci siano. <code class="language-plaintext highlighter-rouge">.order(created_at: :desc)</code> ordina dal più recente, così un nuovo annuncio compare in cima alla pagina invece che in fondo.</li>
  <li><code class="language-plaintext highlighter-rouge">def new</code> / <code class="language-plaintext highlighter-rouge">@service = Current.user.services.new</code> — questo è il ritorno dell’aver collegato <code class="language-plaintext highlighter-rouge">has_many :services</code> a <code class="language-plaintext highlighter-rouge">User</code> una sezione fa. <code class="language-plaintext highlighter-rouge">Current.user</code> è l’utente attualmente autenticato (dal modello <code class="language-plaintext highlighter-rouge">Current</code> dell’episodio 2, una sottoclasse di <code class="language-plaintext highlighter-rouge">ActiveSupport::CurrentAttributes</code>). Chiamare <code class="language-plaintext highlighter-rouge">.services.new</code> <strong>attraverso</strong> l’associazione, invece di <code class="language-plaintext highlighter-rouge">Service.new</code> da solo, pre-riempie automaticamente lo <code class="language-plaintext highlighter-rouge">user_id</code> del nuovo record con <code class="language-plaintext highlighter-rouge">Current.user.id</code> — l’istanza che questa riga costruisce sa già a chi appartiene prima ancora che un solo campo del form sia stato compilato.</li>
  <li><code class="language-plaintext highlighter-rouge">def create</code> — <code class="language-plaintext highlighter-rouge">Current.user.services.new(service_params)</code> fa la stessa costruzione scoped-all’associazione di <code class="language-plaintext highlighter-rouge">new</code>, ma questa volta passando anche i dati del form inviati. Poiché il lato <code class="language-plaintext highlighter-rouge">user</code> dell’associazione è impostato da <code class="language-plaintext highlighter-rouge">Current.user.services</code>, non da nulla in <code class="language-plaintext highlighter-rouge">service_params</code>, non c’è nessun campo <code class="language-plaintext highlighter-rouge">user_id</code> da nessuna parte nei params permessi sotto che un visitatore malintenzionato potrebbe manomettere per rivendicare un annuncio a nome di qualcun altro — il fornitore è chiunque dica <code class="language-plaintext highlighter-rouge">Current.user</code>, punto, non quello che potrebbe dichiarare un campo nascosto del form.</li>
  <li><code class="language-plaintext highlighter-rouge">if @service.save</code> — <code class="language-plaintext highlighter-rouge">save</code> esegue le validazioni e, se passano tutte, effettua l’<code class="language-plaintext highlighter-rouge">INSERT</code> e restituisce <code class="language-plaintext highlighter-rouge">true</code>; se una validazione fallisce, non fa nulla al database e restituisce <code class="language-plaintext highlighter-rouge">false</code> — nessuna eccezione sollevata, per questo è un semplice <code class="language-plaintext highlighter-rouge">if</code>, non un <code class="language-plaintext highlighter-rouge">begin/rescue</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">redirect_to services_path, notice: "Your service is live."</code> — in caso di successo, un vero redirect HTTP verso l’index (questo è lo stesso pattern Post/Redirect/Get discusso più a fondo nell’episodio 3 di <em>ai-with-ruby</em>: reindirizzare dopo una POST che cambia lo stato significa che ricaricare la pagina di risultato non reinvia mai il form). <code class="language-plaintext highlighter-rouge">notice:</code> salva un messaggio one-time nel <code class="language-plaintext highlighter-rouge">flash</code> — sopravvive esattamente a un redirect e poi si cancella da solo, il che è come “Your service is live.” compare al caricamento della pagina immediatamente successiva e sparisce se ricarichi di nuovo.</li>
  <li><code class="language-plaintext highlighter-rouge">render :new, status: :unprocessable_entity</code> — in caso di fallimento, ri-renderizza lo <strong>stesso</strong> form (nessun redirect da nessuna parte) così <code class="language-plaintext highlighter-rouge">@service</code> — ora popolato sia con quello che l’utente ha scritto sia con gli errori di validazione ad esso attaccati — può essere mostrato di nuovo con i suoi dati intatti e i problemi specifici evidenziati. <code class="language-plaintext highlighter-rouge">status: :unprocessable_entity</code> imposta lo status HTTP a 422 invece del 200 di default; il browser renderizza comunque l’HTML in entrambi i casi, ma un 422 dice correttamente a qualsiasi strumento che osserva la risposta (devtools del browser, Turbo, una suite di test) che questo era un invio fallito, non un caricamento di pagina riuscito che per caso contiene un form.</li>
  <li><code class="language-plaintext highlighter-rouge">private</code> — tutto sotto questa riga è chiamabile solo da dentro il controller stesso, non raggiungibile come route.</li>
  <li><code class="language-plaintext highlighter-rouge">def service_params</code> / <code class="language-plaintext highlighter-rouge">params.require(:service).permit(:title, :description, :price, :category_id)</code> — gli strong parameters di Rails. <code class="language-plaintext highlighter-rouge">params.require(:service)</code> solleva immediatamente un’eccezione se i dati del form inviati non hanno affatto una chiave <code class="language-plaintext highlighter-rouge">service</code> di primo livello (una richiesta malformata o mancante), e <code class="language-plaintext highlighter-rouge">.permit(...)</code> è una allowlist: solo queste quattro chiavi sono permesse; qualsiasi altra cosa presente nei params grezzi della richiesta — incluso, per esempio, uno <code class="language-plaintext highlighter-rouge">user_id</code> che qualcuno ha provato a iniettare modificando l’HTML del form nel proprio browser prima di inviare — viene silenziosamente rimossa e non raggiunge mai <code class="language-plaintext highlighter-rouge">Service.new</code>. Nota che questo permette <code class="language-plaintext highlighter-rouge">:price</code>, non <code class="language-plaintext highlighter-rouge">:price_cents</code> — il controller parla con la stessa interfaccia rivolta agli euro del form; non sa nemmeno lui che <code class="language-plaintext highlighter-rouge">price_cents</code> esiste.</li>
</ul>

<h2 id="le-routes-e-i-due-bottoni-placeholder">Le routes e i due bottoni placeholder</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resources</span> <span class="ss">:services</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:index</span><span class="p">,</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">resources :services</code> è la scorciatoia di routing RESTful di Rails — da sola genererebbe tutte e sette le route convenzionali (<code class="language-plaintext highlighter-rouge">index</code>, <code class="language-plaintext highlighter-rouge">new</code>, <code class="language-plaintext highlighter-rouge">create</code>, <code class="language-plaintext highlighter-rouge">show</code>, <code class="language-plaintext highlighter-rouge">edit</code>, <code class="language-plaintext highlighter-rouge">update</code>, <code class="language-plaintext highlighter-rouge">destroy</code>). <code class="language-plaintext highlighter-rouge">only: [:index, :new, :create]</code> la restringe esattamente alle tre che questo episodio implementa. Eseguire <code class="language-plaintext highlighter-rouge">bin/rails routes -g services</code> mostra esattamente cosa è stato generato:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>     Prefix Verb URI Pattern             Controller#Action
   services GET  /services(.:format)     services#index
            POST /services(.:format)     services#create
new_service GET  /services/new(.:format) services#new
</code></pre></div></div>

<p>La colonna <code class="language-plaintext highlighter-rouge">Prefix</code> è da dove vengono i nomi degli helper dei path come <code class="language-plaintext highlighter-rouge">services_path</code> e <code class="language-plaintext highlighter-rouge">new_service_path</code> — usati per tutto il controller e le view in questo episodio: Rails li deriva automaticamente dal prefisso più <code class="language-plaintext highlighter-rouge">_path</code> o <code class="language-plaintext highlighter-rouge">_url</code>. Non c’è ancora una route <code class="language-plaintext highlighter-rouge">show</code>, di proposito — non c’è una pagina del singolo servizio a cui linkare finché un episodio successivo non ne costruisce una.</p>

<p>La landing page dell’episodio 1 era stata spedita con due bottoni che non facevano nulla di proposito, stilizzati per sembrare disabilitati:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"rounded-md bg-indigo-600 px-4 py-2 text-white font-medium opacity-60 cursor-not-allowed"</span><span class="nt">&gt;</span>
  Browse services
<span class="nt">&lt;/span&gt;</span>
<span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"rounded-md border border-gray-300 px-4 py-2 text-gray-700 font-medium opacity-60 cursor-not-allowed"</span><span class="nt">&gt;</span>
  Offer a service
<span class="nt">&lt;/span&gt;</span>
</code></pre></div></div>

<p>Finalmente portano da qualche parte:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Browse services"</span><span class="p">,</span> <span class="n">services_path</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"rounded-md bg-indigo-600 px-4 py-2 text-white font-medium hover:bg-indigo-700"</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Offer a service"</span><span class="p">,</span> <span class="n">authenticated?</span> <span class="p">?</span> <span class="n">new_service_path</span> <span class="p">:</span> <span class="n">new_registration_path</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"rounded-md border border-gray-300 px-4 py-2 text-gray-700 font-medium hover:bg-gray-50"</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>Entrambi i placeholder <code class="language-plaintext highlighter-rouge">&lt;span&gt;</code> sono diventati chiamate <code class="language-plaintext highlighter-rouge">link_to</code>, e le classi <code class="language-plaintext highlighter-rouge">opacity-60 cursor-not-allowed</code> che segnalavano visivamente “non ancora cliccabile” sono sparite insieme allo stato disabilitato stesso.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">link_to "Browse services", services_path, ...</code> — incondizionato. L’index è pubblico (<code class="language-plaintext highlighter-rouge">allow_unauthenticated_access only: :index</code> dal controller), quindi questo link ha sempre senso, autenticati o no.</li>
  <li><code class="language-plaintext highlighter-rouge">link_to "Offer a service", authenticated? ? new_service_path : new_registration_path, ...</code> — la destinazione stessa è un’espressione ternaria. <code class="language-plaintext highlighter-rouge">authenticated?</code> è l’<code class="language-plaintext highlighter-rouge">helper_method</code> che il concern <code class="language-plaintext highlighter-rouge">Authentication</code> dell’episodio 2 espone alle view (è solo <code class="language-plaintext highlighter-rouge">resume_session</code>, riusato per rispondere a “qualcuno è autenticato” senza innescare un redirect come farebbe <code class="language-plaintext highlighter-rouge">require_authentication</code>). Da autenticati, il link va dritto a <code class="language-plaintext highlighter-rouge">new_service_path</code> — il form. Da disconnessi, va a <code class="language-plaintext highlighter-rouge">new_registration_path</code> — registrazione — invece che direttamente al form del servizio, che li rimbalzerebbe comunque immediatamente alla pagina di accesso via <code class="language-plaintext highlighter-rouge">require_authentication</code>. Stessa destinazione finale in entrambi i casi; solo un redirect in meno per il caso comune di un visitatore che non si è ancora autenticato.</li>
</ul>

<h2 id="il-form-offri-un-servizio">Il form “offri un servizio”</h2>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/services/new.html.erb %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"max-w-xl mx-auto"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"text-3xl font-bold text-gray-900"</span><span class="nt">&gt;</span>Offer a service<span class="nt">&lt;/h1&gt;</span>
  <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"mt-2 text-gray-600"</span><span class="nt">&gt;</span>Tell your neighbours what you can help with.<span class="nt">&lt;/p&gt;</span>

  <span class="cp">&lt;%=</span> <span class="n">form_with</span> <span class="ss">model: </span><span class="vi">@service</span><span class="p">,</span> <span class="ss">url: </span><span class="n">services_path</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"mt-8 space-y-5"</span> <span class="k">do</span> <span class="o">|</span><span class="n">form</span><span class="o">|</span> <span class="cp">%&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">if</span> <span class="vi">@service</span><span class="p">.</span><span class="nf">errors</span><span class="p">.</span><span class="nf">any?</span> <span class="cp">%&gt;</span>
      <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"rounded-md bg-red-50 px-4 py-3 text-sm text-red-700"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;ul</span> <span class="na">class=</span><span class="s">"list-disc list-inside"</span><span class="nt">&gt;</span>
          <span class="cp">&lt;%</span> <span class="vi">@service</span><span class="p">.</span><span class="nf">errors</span><span class="p">.</span><span class="nf">full_messages</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">message</span><span class="o">|</span> <span class="cp">%&gt;</span>
            <span class="nt">&lt;li&gt;</span><span class="cp">&lt;%=</span> <span class="n">message</span> <span class="cp">%&gt;</span><span class="nt">&lt;/li&gt;</span>
          <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
        <span class="nt">&lt;/ul&gt;</span>
      <span class="nt">&lt;/div&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>

    <span class="nt">&lt;div&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"block text-sm font-medium text-gray-700"</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">text_field</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">placeholder: </span><span class="s2">"Guitar lessons for beginners"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/div&gt;</span>

    <span class="nt">&lt;div&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:category_id</span><span class="p">,</span> <span class="s2">"Category"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"block text-sm font-medium text-gray-700"</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">collection_select</span> <span class="ss">:category_id</span><span class="p">,</span> <span class="no">Category</span><span class="p">.</span><span class="nf">order</span><span class="p">(</span><span class="ss">:name</span><span class="p">),</span> <span class="ss">:id</span><span class="p">,</span> <span class="ss">:name</span><span class="p">,</span> <span class="p">{</span> <span class="ss">prompt: </span><span class="s2">"Choose a category"</span> <span class="p">},</span> <span class="ss">class: </span><span class="s2">"mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/div&gt;</span>

    <span class="nt">&lt;div&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:description</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"block text-sm font-medium text-gray-700"</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">text_area</span> <span class="ss">:description</span><span class="p">,</span> <span class="ss">rows: </span><span class="mi">4</span><span class="p">,</span> <span class="ss">placeholder: </span><span class="s2">"What you offer, your experience, anything a neighbour should know."</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/div&gt;</span>

    <span class="nt">&lt;div&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:price</span><span class="p">,</span> <span class="s2">"Price (EUR)"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"block text-sm font-medium text-gray-700"</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">text_field</span> <span class="ss">:price</span><span class="p">,</span> <span class="ss">placeholder: </span><span class="s2">"45.00"</span><span class="p">,</span> <span class="ss">inputmode: </span><span class="s2">"decimal"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"mt-1 block w-40 rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/div&gt;</span>

    <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">submit</span> <span class="s2">"Publish"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"rounded-md bg-indigo-600 px-4 py-2 text-white font-medium hover:bg-indigo-700 cursor-pointer"</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">form_with model: @service, url: services_path, ...</code> — <code class="language-plaintext highlighter-rouge">model: @service</code> è ciò che fa funzionare questo unico form sia per un <code class="language-plaintext highlighter-rouge">Service</code> nuovo di zecca, non salvato (da <code class="language-plaintext highlighter-rouge">ServicesController#new</code>), sia, più avanti, per uno che non è riuscito a salvarsi e viene ri-renderizzato con gli errori attaccati (dal <code class="language-plaintext highlighter-rouge">render :new</code> di <code class="language-plaintext highlighter-rouge">#create</code>) — Rails ispeziona se il record è persistito per decidere il metodo HTTP del form, anche se, dato che le routes definiscono solo <code class="language-plaintext highlighter-rouge">create</code>, questo form deve solo fare POST. <code class="language-plaintext highlighter-rouge">url: services_path</code> è esplicito qui invece di essere lasciato dedurre, e punta l’invio alla route <code class="language-plaintext highlighter-rouge">services#create</code> in ogni caso.</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;% if @service.errors.any? %&gt; ... @service.errors.full_messages.each ... &lt;% end %&gt;</code> — dopo una <code class="language-plaintext highlighter-rouge">create</code> fallita, <code class="language-plaintext highlighter-rouge">@service</code> porta con sé sia quello che l’utente ha scritto <strong>sia</strong> i fallimenti di validazione specifici ad esso attaccati da <code class="language-plaintext highlighter-rouge">save</code>. <code class="language-plaintext highlighter-rouge">errors.full_messages</code> li trasforma in stringhe pronte da leggere come “Title can’t be blank” — questo è esattamente il blocco che ha renderizzato le liste di errori mostrate più avanti in questo post.</li>
  <li><code class="language-plaintext highlighter-rouge">form.label :title, ...</code> / <code class="language-plaintext highlighter-rouge">form.text_field :title, ...</code> — un normale input di testo con etichetta. <code class="language-plaintext highlighter-rouge">form.label :title</code> senza un testo esplicito deduce “Title” dal nome dell’attributo automaticamente (tramite lo stesso meccanismo <code class="language-plaintext highlighter-rouge">humanize</code> discusso nella sezione I18n sotto); <code class="language-plaintext highlighter-rouge">placeholder:</code> è solo un attributo HTML passato direttamente.</li>
  <li><code class="language-plaintext highlighter-rouge">form.collection_select :category_id, Category.order(:name), :id, :name, { prompt: "Choose a category" }, class: "..."</code> — questo è l’unico helper nel form su cui vale la pena soffermarsi, perché prende cinque argomenti separati prima dell’hash di opzioni HTML:
    <ol>
      <li><code class="language-plaintext highlighter-rouge">:category_id</code> — l’attributo impostato all’invio (corrisponde alla colonna di foreign key di <code class="language-plaintext highlighter-rouge">belongs_to :category</code>).</li>
      <li><code class="language-plaintext highlighter-rouge">Category.order(:name)</code> — la collezione di record da cui costruire i tag <code class="language-plaintext highlighter-rouge">&lt;option&gt;</code>, ordinata alfabeticamente così il menu a tendina non elenca le categorie nell’ordine in cui sono state seminate.</li>
      <li><code class="language-plaintext highlighter-rouge">:id</code> — il metodo <strong>valore</strong>: per ogni <code class="language-plaintext highlighter-rouge">Category</code> nella collezione, chiama <code class="language-plaintext highlighter-rouge">.id</code> per ottenere ciò che viene effettivamente inviato come <code class="language-plaintext highlighter-rouge">category_id</code>.</li>
      <li><code class="language-plaintext highlighter-rouge">:name</code> — il metodo <strong>testo</strong>: chiama <code class="language-plaintext highlighter-rouge">.name</code> per ottenere ciò che viene mostrato a un umano dentro il menu.</li>
      <li><code class="language-plaintext highlighter-rouge">{ prompt: "Choose a category" }</code> — opzioni per il select stesso; <code class="language-plaintext highlighter-rouge">prompt:</code> inserisce un’opzione placeholder disabilitata e non selezionata con quel testo, così il menu non finisce silenziosamente per selezionare di default la prima categoria della lista se qualcuno invia senza toccarlo.
Solo dopo questo quinto argomento appare il normale hash di attributi HTML <code class="language-plaintext highlighter-rouge">class: "..."</code> — una chiamata a <code class="language-plaintext highlighter-rouge">collection_select</code> separa sempre “come costruire le opzioni” da “quali attributi HTML mettere sul tag <code class="language-plaintext highlighter-rouge">&lt;select&gt;</code>” in questo modo.</li>
    </ol>
  </li>
  <li><code class="language-plaintext highlighter-rouge">form.label :price, "Price (EUR)", ...</code> — qui il testo dell’etichetta <strong>è</strong> dato esplicitamente (“Price (EUR)”), sovrascrivendo ciò che <code class="language-plaintext highlighter-rouge">humanize</code> dal nome dell’attributo avrebbe prodotto (“Price”), perché il form deve comunicare la valuta e il nome dell’attributo del modello non ha modo di portarla da solo.</li>
  <li><code class="language-plaintext highlighter-rouge">form.text_field :price, placeholder: "45.00", inputmode: "decimal", ...</code> — questo è il campo che chiama <code class="language-plaintext highlighter-rouge">Service#price=</code> all’invio, non <code class="language-plaintext highlighter-rouge">price_cents=</code> — la view davvero non menziona mai i centesimi da nessuna parte. <code class="language-plaintext highlighter-rouge">inputmode: "decimal"</code> è un normale attributo HTML (niente di specifico di Rails) che suggerisce alle tastiere mobili di mostrare un tastierino numerico con un punto decimale invece della tastiera alfabetica completa.</li>
  <li><code class="language-plaintext highlighter-rouge">form.submit "Publish", ...</code> — renderizza un <code class="language-plaintext highlighter-rouge">&lt;input type="submit"&gt;</code> con l’etichetta data; <code class="language-plaintext highlighter-rouge">cursor-pointer</code> è puramente estetico, dato che un bottone submit è cliccabile di default ma i browser non sempre renderizzano il cursore a puntatore su di esso senza che venga detto loro di farlo.</li>
</ul>

<h2 id="lelenco-dei-servizi">L’elenco dei servizi</h2>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/services/index.html.erb %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"max-w-3xl mx-auto w-full"</span><span class="nt">&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">if</span> <span class="n">notice</span> <span class="o">=</span> <span class="n">flash</span><span class="p">[</span><span class="ss">:notice</span><span class="p">]</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"py-2 px-3 bg-green-50 mb-5 text-green-700 font-medium rounded-lg inline-block"</span> <span class="na">id=</span><span class="s">"notice"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">notice</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>

  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"flex items-center justify-between"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"text-3xl font-bold text-gray-900"</span><span class="nt">&gt;</span>Services near you<span class="nt">&lt;/h1&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">if</span> <span class="n">authenticated?</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Offer a service"</span><span class="p">,</span> <span class="n">new_service_path</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"rounded-md bg-indigo-600 px-4 py-2 text-white font-medium hover:bg-indigo-700"</span> <span class="cp">%&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>

  <span class="cp">&lt;%</span> <span class="k">if</span> <span class="vi">@services</span><span class="p">.</span><span class="nf">none?</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"mt-8 text-gray-500"</span><span class="nt">&gt;</span>No services yet — be the first to offer one.<span class="nt">&lt;/p&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">else</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"mt-8 space-y-4"</span><span class="nt">&gt;</span>
      <span class="cp">&lt;%</span> <span class="vi">@services</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">service</span><span class="o">|</span> <span class="cp">%&gt;</span>
        <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"rounded-lg border border-gray-200 p-5"</span><span class="nt">&gt;</span>
          <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"flex items-start justify-between gap-4"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;div&gt;</span>
              <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-xs font-semibold uppercase tracking-wide text-indigo-600"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">service</span><span class="p">.</span><span class="nf">category</span><span class="p">.</span><span class="nf">name</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
              <span class="nt">&lt;h2</span> <span class="na">class=</span><span class="s">"mt-1 text-lg font-semibold text-gray-900"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">service</span><span class="p">.</span><span class="nf">title</span> <span class="cp">%&gt;</span><span class="nt">&lt;/h2&gt;</span>
              <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"mt-1 text-sm text-gray-500"</span><span class="nt">&gt;</span>by <span class="cp">&lt;%=</span> <span class="n">service</span><span class="p">.</span><span class="nf">user</span><span class="p">.</span><span class="nf">email_address</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
            <span class="nt">&lt;/div&gt;</span>
            <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"whitespace-nowrap text-lg font-semibold text-gray-900"</span><span class="nt">&gt;</span>
              <span class="cp">&lt;%=</span> <span class="n">number_to_currency</span><span class="p">(</span><span class="n">service</span><span class="p">.</span><span class="nf">price</span><span class="p">)</span> <span class="cp">%&gt;</span>
            <span class="nt">&lt;/p&gt;</span>
          <span class="nt">&lt;/div&gt;</span>
          <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"mt-3 text-gray-600"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">service</span><span class="p">.</span><span class="nf">description</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
        <span class="nt">&lt;/div&gt;</span>
      <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">&lt;% if notice = flash[:notice] %&gt; ... &lt;% end %&gt;</code> — un singolo <code class="language-plaintext highlighter-rouge">=</code> di proposito, un’assegnazione usata come condizione, non un confronto <code class="language-plaintext highlighter-rouge">==</code>. <code class="language-plaintext highlighter-rouge">flash[:notice]</code> viene letto una volta, assegnato a una locale <code class="language-plaintext highlighter-rouge">notice</code>, e quella stessa locale viene riusata dentro il blocco — questo è esattamente il pattern già usato in <code class="language-plaintext highlighter-rouge">sessions/new.html.erb</code> dall’episodio 2, tenuto coerente qui invece di introdurre un idioma diverso. È ciò che fa comparire davvero sullo schermo il messaggio “Your service is live.” dal <code class="language-plaintext highlighter-rouge">redirect_to ..., notice: "..."</code> del controller — nulla renderizza <code class="language-plaintext highlighter-rouge">flash</code> automaticamente da nessuna parte in questo layout, ogni view che vuole mostrarlo lo fa esplicitamente.</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;% if authenticated? %&gt; ... &lt;% end %&gt;</code> intorno al bottone “Offer a service” — un visitatore disconnesso che sfoglia l’index pubblico vede gli annunci ma non un invito a pubblicarne uno; il bottone appare solo una volta che qualcuno è effettivamente autenticato.</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;% if @services.none? %&gt; ... &lt;% else %&gt; ... &lt;% end %&gt;</code> — <code class="language-plaintext highlighter-rouge">.none?</code> è una normale query ActiveRecord/Enumerable, vera quando la relation ha zero record. Questo è il messaggio di stato vuoto, mostrato al posto di una pagina vuota senza spiegazione la prima volta che l’app gira senza ancora nessun annuncio.</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;% @services.each do |service| %&gt;</code> — itera la relation caricata in anticipo dal controller. Poiché <code class="language-plaintext highlighter-rouge">Service.includes(:category, :user)</code> ha già portato ogni categoria e ogni utente in memoria insieme ai servizi stessi, <code class="language-plaintext highlighter-rouge">service.category.name</code> e <code class="language-plaintext highlighter-rouge">service.user.email_address</code> dentro questo loop non innescano nessuna query aggiuntiva — questo è il ritorno della chiamata <code class="language-plaintext highlighter-rouge">includes</code> discussa nella sezione del controller, che atterra qui, nella view, dove l’N+1 altrimenti scatterebbe davvero.</li>
  <li><code class="language-plaintext highlighter-rouge">service.category.name</code>, <code class="language-plaintext highlighter-rouge">service.title</code>, <code class="language-plaintext highlighter-rouge">service.user.email_address</code>, <code class="language-plaintext highlighter-rouge">service.description</code> — normali letture di attributi e associazioni.</li>
  <li><code class="language-plaintext highlighter-rouge">number_to_currency(service.price)</code> — un helper di view di Rails (da <code class="language-plaintext highlighter-rouge">ActionView::Helpers::NumberHelper</code>) che formatta un numero semplice come valuta: <code class="language-plaintext highlighter-rouge">42.5</code> diventa <code class="language-plaintext highlighter-rouge">"€42.50"</code> — il numero corretto di due decimali e il simbolo della valuta, non qualsiasi numero di cifre abbia per caso il float. Questo chiama <code class="language-plaintext highlighter-rouge">service.price</code>, il lettore virtuale rivolto agli euro definito sul modello — non <code class="language-plaintext highlighter-rouge">service.price_cents</code> — quindi il numero sullo schermo è davvero <code class="language-plaintext highlighter-rouge">42.5</code>, formattato, mai <code class="language-plaintext highlighter-rouge">4250</code>.</li>
</ul>

<p>Lasciato completamente senza configurazione, <code class="language-plaintext highlighter-rouge">number_to_currency</code> usa di default i dollari americani — la valuta di VicinoTe è l’euro, quindi quel default andava sovrascritto una volta, globalmente, invece di passare <code class="language-plaintext highlighter-rouge">unit: "€"</code> a ogni chiamata:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/locales/en.yml</span>
<span class="na">en</span><span class="pi">:</span>
  <span class="na">number</span><span class="pi">:</span>
    <span class="na">currency</span><span class="pi">:</span>
      <span class="na">format</span><span class="pi">:</span>
        <span class="na">unit</span><span class="pi">:</span> <span class="s2">"</span><span class="s">€"</span>
        <span class="na">format</span><span class="pi">:</span> <span class="s2">"</span><span class="s">%u%n"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">unit:</code> è il simbolo stesso. <code class="language-plaintext highlighter-rouge">format:</code> è il template che lo posiziona rispetto al numero — <code class="language-plaintext highlighter-rouge">%u</code> è l’unità, <code class="language-plaintext highlighter-rouge">%n</code> è il numero formattato, quindi <code class="language-plaintext highlighter-rouge">"%u%n"</code> significa “simbolo, poi numero, senza spazio,” che è ciò che produce <code class="language-plaintext highlighter-rouge">€42.50</code> invece di <code class="language-plaintext highlighter-rouge">€ 42.50</code> o <code class="language-plaintext highlighter-rouge">42.50€</code>. Entrambe le chiamate a <code class="language-plaintext highlighter-rouge">number_to_currency</code> nell’app — ce n’è solo una, in <code class="language-plaintext highlighter-rouge">index.html.erb</code> — recepiscono questo automaticamente senza modifiche al punto di chiamata, perché è il default stesso a essersi spostato, non la chiamata.</p>

<h2 id="una-trappola-solo-nelle-view-lerrore-diceva-price-cents">Una trappola solo nelle view: l’errore diceva “price cents”</h2>

<p>Inviare il form vuoto la prima volta, prima di sistemare questo, produceva:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Category must exist
Title can't be blank
Description can't be blank
Price cents can't be blank
Price cents is not a number
</code></pre></div></div>

<p>Tutto il resto si legge naturalmente — “Title can’t be blank” — perché Rails deriva l’etichetta leggibile dal nome dell’attributo tramite <code class="language-plaintext highlighter-rouge">humanize</code>. Ma l’attributo effettivamente validato è <code class="language-plaintext highlighter-rouge">price_cents</code>, non <code class="language-plaintext highlighter-rouge">price</code>, quindi è quel nome a essere trapelato nel messaggio. Un utente che compila questo form non ha mai sentito parlare di <code class="language-plaintext highlighter-rouge">price_cents</code>; il campo del form appena sotto l’errore dice semplicemente “Price (EUR)”.</p>

<p>La correzione è un override I18n di una riga, non una modifica al modello — la validazione è corretta, solo il suo nome renderizzato era sbagliato:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/locales/en.yml</span>
<span class="na">en</span><span class="pi">:</span>
  <span class="na">activerecord</span><span class="pi">:</span>
    <span class="na">attributes</span><span class="pi">:</span>
      <span class="na">service</span><span class="pi">:</span>
        <span class="na">price_cents</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Price"</span>
</code></pre></div></div>

<p>Questo è il lookup I18n di Rails per <code class="language-plaintext highlighter-rouge">human_attribute_name</code>: prima di ricadere su un <code class="language-plaintext highlighter-rouge">humanize</code> automatico del nome dell’attributo, ActiveRecord controlla <code class="language-plaintext highlighter-rouge">activerecord.attributes.&lt;model&gt;.&lt;attributo&gt;</code> nei file di locale, e usa qualsiasi stringa trovi lì esattamente com’è, per ogni messaggio che menziona quell’attributo — errori di validazione, <code class="language-plaintext highlighter-rouge">form.label</code> senza testo esplicito, ovunque.</p>

<p>Un dettaglio che è costato un secondo passaggio: scrivere l’override come <code class="language-plaintext highlighter-rouge">price</code> (minuscolo) produceva “price can’t be blank” — minuscolo, incoerente con “Title” e “Description” accanto. Lo <code class="language-plaintext highlighter-rouge">humanize</code> di default di Rails mette la maiuscola alla prima lettera automaticamente come parte di ciò che fa; una stringa I18n personalizzata viene usata esattamente come scritta, senza nessuna maiuscola applicata sopra. La correzione è stata semplicemente mettere la maiuscola nell’override stesso, <code class="language-plaintext highlighter-rouge">"Price"</code>.</p>

<h2 id="provarlo">Provarlo</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Registrati (o accedi), clicca “Offer a service”, inserisci un titolo, scegli una categoria, scrivi una descrizione, e un prezzo tipo <code class="language-plaintext highlighter-rouge">42.50</code>. Invia, e reindirizza a <code class="language-plaintext highlighter-rouge">/services</code> con “Your service is live.” in un banner flash, l’annuncio subito sotto — categoria, titolo, chi l’ha pubblicato, descrizione, e <code class="language-plaintext highlighter-rouge">€42.50</code>, non <code class="language-plaintext highlighter-rouge">4250</code>. Disconnettiti e visita <code class="language-plaintext highlighter-rouge">/services</code> direttamente: è ancora lì, ancora pubblico. Prova <code class="language-plaintext highlighter-rouge">/services/new</code> da disconnesso e reindirizza all’accesso, come qualsiasi altra pagina protetta. Invia il form con tutto vuoto e compare il messaggio di validazione di ogni campo, in inglese semplice e correttamente maiuscolo, “price_cents” da nessuna parte.</p>

<h2 id="cosa-viene-dopo">Cosa viene dopo</h2>

<p>L’episodio 4 costruisce <code class="language-plaintext highlighter-rouge">Booking</code> — il record di un accordo tra due utenti, e il flusso che permette davvero a qualcuno di prenotare un servizio elencato.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[L’episodio 2 aveva fatto funzionare gli account da capo a fondo — registrazione, accesso, disconnessione, reset della password — e si era fermato lì di proposito: nulla toccava ancora Service o Booking. Questo episodio è dove il marketplace inizia davvero a essere un marketplace: un utente autenticato può elencare qualcosa che offre, e chiunque può sfogliare cosa è elencato.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/vicinote-ep3-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/vicinote-ep3-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="it"><title type="html">Streaming delle risposte RubyLLM con Turbo Streams</title><link href="https://antoninoscaffidi.github.io/it/streaming-responses-with-turbo-streams/" rel="alternate" type="text/html" title="Streaming delle risposte RubyLLM con Turbo Streams" /><published>2026-08-19T05:00:00+00:00</published><updated>2026-08-19T05:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/streaming-responses-with-turbo-streams.it</id><content type="html" xml:base="https://antoninoscaffidi.github.io/streaming-responses-with-turbo-streams/"><![CDATA[<p>Nell’<a href="/it/persisting-conversations-with-activerecord/">episodio 3</a> le conversazioni avevano iniziato a sopravvivere a un refresh della pagina, ma la richiesta in sé restava una scatola nera: premi Invia, e l’intera pagina resta lì ferma — nessuno spinner, niente — finché l’intera risposta non torna dal modello, e solo allora la pagina reindirizza e la mostra, tutta insieme. Per una risposta di una frase sono uno o due secondi di nulla. Per una più lunga, possono essere cinque, sei secondi di una pagina che sembra bloccata.</p>

<p>Questo episodio risolve il problema: la risposta ora si scrive da sola sulla pagina man mano che il modello la genera, un token alla volta, senza ricaricare l’intera pagina. Nel frattempo chiudiamo anche un TODO rimasto dall’episodio 3 — non c’era modo di iniziare una conversazione nuova; <code class="language-plaintext highlighter-rouge">session[:conversation_id]</code> teneva sempre la stessa per sempre.</p>

<p>Il codice è taggato <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo/tree/episode-4"><code class="language-plaintext highlighter-rouge">episode-4</code></a> nel repo <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo">ai-with-ruby-demo</a>. Avviso onesto in anticipo: questo episodio ha incontrato due bug veri costruendolo, e li lascio entrambi nel post con i messaggi di errore esatti e il ragionamento esatto che ha portato alla correzione — quella traccia di debugging è probabilmente più utile del codice finale da solo.</p>

<h2 id="come-rubyllm-fa-streaming-di-una-risposta-dallinterno">Come RubyLLM fa streaming di una risposta, dall’interno</h2>

<p>Prima di toccare qualsiasi nostro codice, vale la pena aprire il sorgente di RubyLLM e leggere <code class="language-plaintext highlighter-rouge">ask</code> e <code class="language-plaintext highlighter-rouge">complete</code>, perché tutto quello che costruiamo in questo episodio si appoggia sul loro comportamento esatto. Ecco la parte rilevante di <a href="https://github.com/crmne/ruby_llm"><code class="language-plaintext highlighter-rouge">chat_methods.rb</code></a>, il modulo che <code class="language-plaintext highlighter-rouge">acts_as_chat</code> mescola in <code class="language-plaintext highlighter-rouge">Conversation</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">ask</span><span class="p">(</span><span class="n">message</span> <span class="o">=</span> <span class="kp">nil</span><span class="p">,</span> <span class="ss">with: </span><span class="kp">nil</span><span class="p">,</span> <span class="o">&amp;</span><span class="p">)</span>
  <span class="n">add_message</span><span class="p">(</span><span class="ss">role: :user</span><span class="p">,</span> <span class="ss">content: </span><span class="n">build_content</span><span class="p">(</span><span class="n">message</span><span class="p">,</span> <span class="n">with</span><span class="p">))</span>
  <span class="n">complete</span><span class="p">(</span><span class="o">&amp;</span><span class="p">)</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">complete</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
  <span class="n">to_llm</span><span class="p">.</span><span class="nf">complete</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">setup_persistence_callbacks</span>
  <span class="k">return</span> <span class="vi">@chat</span> <span class="k">if</span> <span class="vi">@chat</span><span class="p">.</span><span class="nf">instance_variable_get</span><span class="p">(</span><span class="ss">:@_persistence_callbacks_setup</span><span class="p">)</span>

  <span class="vi">@chat</span><span class="p">.</span><span class="nf">before_message</span> <span class="p">{</span> <span class="n">persist_new_message</span> <span class="p">}</span>
  <span class="vi">@chat</span><span class="p">.</span><span class="nf">after_message</span> <span class="p">{</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span> <span class="n">persist_message_completion</span><span class="p">(</span><span class="n">msg</span><span class="p">)</span> <span class="p">}</span>

  <span class="vi">@chat</span><span class="p">.</span><span class="nf">instance_variable_set</span><span class="p">(</span><span class="ss">:@_persistence_callbacks_setup</span><span class="p">,</span> <span class="kp">true</span><span class="p">)</span>
  <span class="vi">@chat</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">persist_new_message</span>
  <span class="vi">@message</span> <span class="o">=</span> <span class="n">messages_association</span><span class="p">.</span><span class="nf">create!</span><span class="p">(</span><span class="ss">role: :assistant</span><span class="p">,</span> <span class="ss">content: </span><span class="s1">''</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Da qui emergono quattro cose che contano molto per quello che stiamo per costruire:</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">ask</code> salva da sola il messaggio dell’utente</strong>, in modo sincrono, prima di fare qualsiasi altra cosa. Se il tuo codice <strong>inserisce anche</strong> una riga <code class="language-plaintext highlighter-rouge">Message</code> utente prima di chiamare <code class="language-plaintext highlighter-rouge">ask</code>, ottieni due righe per una sola domanda — vedremo esattamente questo errore più sotto.</li>
  <li><strong>Il messaggio assistant vuoto viene creato prima che esista qualsiasi testo.</strong> <code class="language-plaintext highlighter-rouge">persist_new_message</code> gira in un callback <code class="language-plaintext highlighter-rouge">before_message</code> e crea un <code class="language-plaintext highlighter-rouge">Message</code> con <code class="language-plaintext highlighter-rouge">content: ''</code>. Quindi nel momento in cui il modello inizia a parlare, esiste già una riga nel database — con un id — in attesa di essere riempita.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">ask</code>/<code class="language-plaintext highlighter-rouge">complete</code> accettano un blocco</strong>, e quel blocco è l’aggancio di streaming di RubyLLM — viene invocato una volta per ogni chunk di testo man mano che il provider lo restituisce in streaming, ben prima che la risposta sia completa.</li>
  <li><strong>Il contenuto finale viene scritto una sola volta, alla fine</strong>, da <code class="language-plaintext highlighter-rouge">persist_message_completion</code>, in un callback <code class="language-plaintext highlighter-rouge">after_message</code> — un normale <code class="language-plaintext highlighter-rouge">UPDATE</code> sulla stessa riga creata vuota.</li>
</ol>

<p>Messo insieme: una chiamata a <code class="language-plaintext highlighter-rouge">.ask(content, &amp;block)</code> crea due righe (utente, poi assistant vuoto), poi chiama il tuo blocco ripetutamente man mano che arrivano i chunk, poi fa un <code class="language-plaintext highlighter-rouge">UPDATE</code> finale con il testo completo e i conteggi dei token. Nulla nella persistenza cambia se passi un blocco — l’unica cosa che un blocco aggiunge è un callback che scatta per ogni chunk. Lo streaming, in altre parole, non è un percorso di codice diverso; è esattamente la stessa <code class="language-plaintext highlighter-rouge">ask</code> che usiamo dall’episodio 3, con un blocco attaccato.</p>

<h2 id="il-piano-un-job-in-background-non-unazione-controller-più-lenta">Il piano: un job in background, non un’azione controller più lenta</h2>

<p>Il ciclo request/response è per sua natura la forma sbagliata per questo. Una risposta HTTP deve concludersi prima che il browser possa renderizzarla — non puoi far uscire una view Rails a piccoli pezzi nel tempo tramite un normale <code class="language-plaintext highlighter-rouge">render</code>. Quindi la chiamata all’LLM deve uscire del tutto dalla request, in un job in background, e il job deve spingere ogni chunk al browser attraverso un canale laterale man mano che arriva. In un’app Rails 8, quel canale laterale sono i Turbo Stream consegnati via Action Cable — nessun framework JavaScript separato, nessun collegamento manuale di WebSocket.</p>

<p>Invece di inventarmi questo pattern a mano, sono andato a vedere come lo raccomanda RubyLLM stesso. La gemma include un generator, <code class="language-plaintext highlighter-rouge">ruby_llm:chat_ui</code>, costruito esattamente per questo. Eseguendolo con <code class="language-plaintext highlighter-rouge">--pretend</code> (non scrive nulla su disco) sulla nostra app, rimappato sul nome reale del nostro modello:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate ruby_llm:chat_ui chat:Conversation <span class="nt">--pretend</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>create  app/views/conversations/{index,new,show,_conversation,_form}.html.erb
create  app/views/messages/{_assistant,_user,_system,_tool,_error,_content,_form}.html.erb
create  app/views/messages/tool_calls/_default.html.erb
create  app/views/messages/tool_results/_default.html.erb
create  app/views/messages/create.turbo_stream.erb
create  app/views/models/{index,show,_model}.html.erb
create  app/controllers/conversations_controller.rb
create  app/controllers/messages_controller.rb
create  app/controllers/models_controller.rb
create  app/jobs/conversation_response_job.rb
insert  app/models/message.rb
 route  resources :conversations { resources :messages, only: [:create] }
 route  resources :models, only: [:index, :show] { collection { post :refresh } }
</code></pre></div></div>

<p>Quello è uno scaffold CRUD completo multi-conversazione — una pagina index e una show per conversazione, un controller per sfogliare i record <code class="language-plaintext highlighter-rouge">Model</code> disponibili, view per tool call e tool result (per un episodio sul tool calling che non abbiamo ancora scritto). La nostra app deliberatamente non è fatta così: dall’episodio 3 esiste esattamente una conversazione implicita per sessione del browser, nessun elenco, nessuna pagina show separata. Adottare l’intero scaffold significherebbe riscrivere la forma dell’app per adattarla al generator, non il contrario.</p>

<p>Quindi invece di eseguirlo, ho letto i template e ne ho tirato fuori i tre pezzi che riguardano davvero lo streaming, adattati per mantenere il design a sessione singola dell’episodio 3:</p>

<ul>
  <li>Un concern del modello che permette a un <code class="language-plaintext highlighter-rouge">Message</code> di trasmettere se stesso via Turbo Stream.</li>
  <li>Un job in background che chiama <code class="language-plaintext highlighter-rouge">ask</code> con un blocco e inoltra ogni chunk al browser.</li>
  <li>Un controller che arruola il job e si toglie di mezzo immediatamente.</li>
</ul>

<h2 id="far-sì-che-message-trasmetta-se-stesso">Far sì che Message trasmetta se stesso</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/message.rb</span>
<span class="k">class</span> <span class="nc">Message</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">acts_as_message</span> <span class="ss">chat: :conversation</span>

  <span class="n">broadcasts_to</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">message</span><span class="p">)</span> <span class="p">{</span> <span class="s2">"conversation_</span><span class="si">#{</span><span class="n">message</span><span class="p">.</span><span class="nf">conversation_id</span><span class="si">}</span><span class="s2">"</span> <span class="p">},</span> <span class="ss">inserts_by: :append</span>

  <span class="k">def</span> <span class="nf">broadcast_append_chunk</span><span class="p">(</span><span class="n">content</span><span class="p">)</span>
    <span class="n">broadcast_append_to</span> <span class="s2">"conversation_</span><span class="si">#{</span><span class="n">conversation_id</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span>
      <span class="ss">target: </span><span class="s2">"message_</span><span class="si">#{</span><span class="nb">id</span><span class="si">}</span><span class="s2">_content"</span><span class="p">,</span>
      <span class="ss">content: </span><span class="no">ERB</span><span class="o">::</span><span class="no">Util</span><span class="p">.</span><span class="nf">html_escape</span><span class="p">(</span><span class="n">content</span><span class="p">.</span><span class="nf">to_s</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">broadcasts_to</code> è l’integrazione ActiveRecord propria di Turbo Streams (dalla gemma <code class="language-plaintext highlighter-rouge">turbo-rails</code>, non da RubyLLM), e fa più di quanto suggerisca quell’unica riga. La sua definizione reale, in <a href="https://github.com/hotwired/turbo-rails"><code class="language-plaintext highlighter-rouge">turbo/broadcastable.rb</code></a>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">broadcasts_to</span><span class="p">(</span><span class="n">stream</span><span class="p">,</span> <span class="ss">inserts_by: :append</span><span class="p">,</span> <span class="ss">target: </span><span class="n">broadcast_target_default</span><span class="p">,</span> <span class="o">**</span><span class="n">rendering</span><span class="p">)</span>
  <span class="n">after_create_commit</span>  <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">broadcast_action_later_to</span><span class="p">(</span><span class="n">stream</span><span class="p">.</span><span class="nf">try</span><span class="p">(</span><span class="ss">:call</span><span class="p">,</span> <span class="nb">self</span><span class="p">)</span> <span class="o">||</span> <span class="nb">send</span><span class="p">(</span><span class="n">stream</span><span class="p">),</span> <span class="ss">action: </span><span class="n">inserts_by</span><span class="p">,</span> <span class="ss">target: </span><span class="n">target</span><span class="p">.</span><span class="nf">try</span><span class="p">(</span><span class="ss">:call</span><span class="p">,</span> <span class="nb">self</span><span class="p">)</span> <span class="o">||</span> <span class="n">target</span><span class="p">,</span> <span class="o">**</span><span class="n">rendering</span><span class="p">)</span> <span class="p">}</span>
  <span class="n">after_update_commit</span>  <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">broadcast_replace_later_to</span><span class="p">(</span><span class="n">stream</span><span class="p">.</span><span class="nf">try</span><span class="p">(</span><span class="ss">:call</span><span class="p">,</span> <span class="nb">self</span><span class="p">)</span> <span class="o">||</span> <span class="nb">send</span><span class="p">(</span><span class="n">stream</span><span class="p">),</span> <span class="o">**</span><span class="n">rendering</span><span class="p">)</span> <span class="p">}</span>
  <span class="n">after_destroy_commit</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">broadcast_remove_to</span><span class="p">(</span><span class="n">stream</span><span class="p">.</span><span class="nf">try</span><span class="p">(</span><span class="ss">:call</span><span class="p">,</span> <span class="nb">self</span><span class="p">)</span> <span class="o">||</span> <span class="nb">send</span><span class="p">(</span><span class="n">stream</span><span class="p">))</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Quindi una chiamata a <code class="language-plaintext highlighter-rouge">broadcasts_to</code> collega <strong>tre</strong> callback, non una:</p>

<ul>
  <li><strong>Alla creazione</strong>, appende una copia renderizzata del messaggio nello stream, su <code class="language-plaintext highlighter-rouge">target:</code> (di default <code class="language-plaintext highlighter-rouge">model_name.plural</code>, cioè <code class="language-plaintext highlighter-rouge">"messages"</code> — l’id di un elemento contenitore che la pagina deve fornire).</li>
  <li><strong>All’aggiornamento</strong>, sostituisce l’elemento del messaggio stesso (target di default: se stesso, <code class="language-plaintext highlighter-rouge">dom_id(message)</code>) con una copia appena renderizzata.</li>
  <li><strong>Alla distruzione</strong>, lo rimuove.</li>
</ul>

<p>Quella di mezzo conta molto qui, ed è facile perdersela a una prima lettura: ogni volta che una riga <code class="language-plaintext highlighter-rouge">Message</code> viene aggiornata — incluso l’<code class="language-plaintext highlighter-rouge">UPDATE</code> finale di <code class="language-plaintext highlighter-rouge">persist_message_completion</code> che scrive il testo completo — l’<strong>intero fumetto del messaggio</strong> viene sostituito con un render fresco. È una bella rete di sicurezza: significa che l’ultimissimo aggiornamento sovrascrive sempre qualunque cosa gli append incrementali avessero lasciato con una copia autorevole, appena renderizzata dal database. Significa anche che un aggiornamento incidentale (come vedremo più sotto) può innescare un broadcast che non avevi chiesto.</p>

<p><code class="language-plaintext highlighter-rouge">broadcast_append_chunk</code> è nostro, non di Turbo — non sta appendendo un nuovo <strong>messaggio</strong>, sta appendendo testo grezzo dentro un messaggio che esiste già, puntando a un <code class="language-plaintext highlighter-rouge">&lt;div&gt;</code> interno specifico (<code class="language-plaintext highlighter-rouge">message_#{id}_content</code>) invece che al contenitore esterno del messaggio. <code class="language-plaintext highlighter-rouge">ERB::Util.html_escape</code> conta qui: il contenuto del chunk arriva direttamente dal modello, non escapato, e questo sta renderizzando HTML grezzo nella pagina — saltare l’escape e una risposta contenente <code class="language-plaintext highlighter-rouge">&lt;</code> o <code class="language-plaintext highlighter-rouge">&amp;</code> corromperebbe il markup (o, peggio, diventerebbe un vettore di injection se il modello ripetesse mai qualcosa che un utente ha scritto).</p>

<h2 id="il-job-in-background">Il job in background</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/jobs/conversation_response_job.rb</span>
<span class="k">class</span> <span class="nc">ConversationResponseJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">conversation_id</span><span class="p">,</span> <span class="n">content</span><span class="p">)</span>
    <span class="n">conversation</span> <span class="o">=</span> <span class="no">Conversation</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">conversation_id</span><span class="p">)</span>
    <span class="n">assistant_message</span> <span class="o">=</span> <span class="kp">nil</span>

    <span class="n">conversation</span><span class="p">.</span><span class="nf">ask</span><span class="p">(</span><span class="n">content</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">chunk</span><span class="o">|</span>
      <span class="k">next</span> <span class="k">if</span> <span class="n">chunk</span><span class="p">.</span><span class="nf">content</span><span class="p">.</span><span class="nf">blank?</span>

      <span class="n">assistant_message</span> <span class="o">||=</span> <span class="n">conversation</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">last</span>
      <span class="n">assistant_message</span><span class="p">.</span><span class="nf">broadcast_append_chunk</span><span class="p">(</span><span class="n">chunk</span><span class="p">.</span><span class="nf">content</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>È quasi il job del generator stesso, con una modifica deliberata. La versione del generator chiama <code class="language-plaintext highlighter-rouge">conversation.messages.last</code> <strong>dentro</strong> il blocco, a ogni singolo chunk — una query SQL fresca per ogni chunk, per un messaggio che non cambia mai lungo l’intera chiamata. Sappiamo già, dalla lettura di <code class="language-plaintext highlighter-rouge">ask</code>/<code class="language-plaintext highlighter-rouge">complete</code> qui sopra, esattamente <strong>quando</strong> viene creato quel messaggio assistant: nel callback <code class="language-plaintext highlighter-rouge">before_message</code>, che scatta una sola volta, prima che venga mai restituito il primo chunk. Quindi la riga esiste e il suo id è fissato prima che il nostro blocco giri anche solo una volta — non c’è nulla da ricercare di nuovo dopo il primo chunk. <code class="language-plaintext highlighter-rouge">assistant_message ||= conversation.messages.last</code> lo recupera una volta e riusa lo stesso record in memoria per ogni chiamata successiva a <code class="language-plaintext highlighter-rouge">broadcast_append_chunk</code>.</p>

<p><code class="language-plaintext highlighter-rouge">chunk.content.blank?</code> protegge dai chunk che portano metadati ma nessun testo (alcuni provider trasmettono un chunk finale con statistiche d’uso e <code class="language-plaintext highlighter-rouge">content</code> vuoto) — niente da appendere, e niente da trasmettere.</p>

<h2 id="il-controller-arruola-poi-togliti-di-mezzo">Il controller: arruola, poi togliti di mezzo</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/chats_controller.rb</span>
<span class="k">class</span> <span class="nc">ChatsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@conversation</span> <span class="o">=</span> <span class="n">current_conversation</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="no">ConversationResponseJob</span><span class="p">.</span><span class="nf">perform_later</span><span class="p">(</span><span class="n">current_conversation</span><span class="p">.</span><span class="nf">id</span><span class="p">,</span> <span class="n">params</span><span class="p">[</span><span class="ss">:message</span><span class="p">])</span>

    <span class="n">respond_to</span> <span class="k">do</span> <span class="o">|</span><span class="nb">format</span><span class="o">|</span>
      <span class="nb">format</span><span class="p">.</span><span class="nf">turbo_stream</span>
      <span class="nb">format</span><span class="p">.</span><span class="nf">html</span> <span class="p">{</span> <span class="n">redirect_to</span> <span class="n">new_chat_path</span> <span class="p">}</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">destroy</span>
    <span class="n">session</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="ss">:conversation_id</span><span class="p">)</span>
    <span class="n">redirect_to</span> <span class="n">new_chat_path</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">current_conversation</span>
    <span class="no">Conversation</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">session</span><span class="p">[</span><span class="ss">:conversation_id</span><span class="p">])</span> <span class="o">||</span> <span class="n">create_conversation</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create_conversation</span>
    <span class="n">conversation</span> <span class="o">=</span> <span class="no">Conversation</span><span class="p">.</span><span class="nf">create!</span>
    <span class="n">session</span><span class="p">[</span><span class="ss">:conversation_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">conversation</span><span class="p">.</span><span class="nf">id</span>
    <span class="n">conversation</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>La prima versione di questo metodo che ho scritto creava anche la riga <code class="language-plaintext highlighter-rouge">Message</code> dell’utente direttamente nel controller, prima di arruolare il job — l’errore menzionato proprio all’inizio di questo post. <code class="language-plaintext highlighter-rouge">ask</code> lo fa già internamente, quindi la riga che aggiungevo a mano era un vero, secondo messaggio utente duplicato per la stessa domanda. La correzione è stata semplicemente cancellare quella riga; <code class="language-plaintext highlighter-rouge">ConversationResponseJob.perform_later</code> basta, dato che <code class="language-plaintext highlighter-rouge">conversation.ask(content)</code> dentro il job lo crea.</p>

<p><code class="language-plaintext highlighter-rouge">create</code> non reindirizza più in caso di successo. Il pattern Post/Redirect/Get dell’episodio 3 non scompare — resta lì come fallback <code class="language-plaintext highlighter-rouge">format.html</code> per una richiesta semplice, senza JS — ma con Turbo che fa il suo lavoro, la richiesta che invia il form riceve invece una risposta <code class="language-plaintext highlighter-rouge">format.turbo_stream</code>, e la pagina in sé non si ricarica mai. Tutto quello che l’utente vede dopo — il proprio messaggio che appare, il fumetto assistant che appare vuoto, il testo che si scrive da solo — arriva più tardi, sulla connessione Action Cable già aperta, non come parte di questa risposta.</p>

<p><code class="language-plaintext highlighter-rouge">destroy</code> è l’intera correzione per il TODO rimasto dell’episodio 3: dimentica l’id della conversazione nella sessione e torna a <code class="language-plaintext highlighter-rouge">new</code>, che ne crea una nuova. Collegarlo alle routes è servita una parola:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resource</span> <span class="ss">:chat</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">,</span> <span class="ss">:destroy</span><span class="p">]</span>
</code></pre></div></div>

<h2 id="la-risposta-turbo_stream-svuotare-il-form">La risposta turbo_stream: svuotare il form</h2>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/chats/create.turbo_stream.erb %&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">turbo_stream</span><span class="p">.</span><span class="nf">replace</span> <span class="s2">"new_message"</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">render</span> <span class="s2">"form"</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>Questo è <strong>l’intero</strong> corpo della risposta HTTP a una <code class="language-plaintext highlighter-rouge">POST /chat</code> ora. Non tocca affatto la conversazione o i messaggi — il suo unico compito è sostituire con un form nuovo e vuoto, così la textarea si svuota da sola dopo l’invio. Tutto il resto succede più tardi, sulla connessione cable, dal job in background.</p>

<p>Il form stesso è stato spostato nel suo partial, così sia questa risposta sia la pagina iniziale possono renderizzarlo:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/chats/_form.html.erb %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"new_message"</span><span class="nt">&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">form_with</span> <span class="ss">url: </span><span class="n">chat_path</span><span class="p">,</span> <span class="ss">method: :post</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"flex flex-col gap-3"</span> <span class="k">do</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;textarea</span>
      <span class="na">name=</span><span class="s">"message"</span>
      <span class="na">rows=</span><span class="s">"3"</span>
      <span class="na">placeholder=</span><span class="s">"Ask something..."</span>
      <span class="na">class=</span><span class="s">"border border-gray-300 rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span>
    <span class="nt">&gt;&lt;/textarea&gt;</span>

    <span class="nt">&lt;button</span>
      <span class="na">type=</span><span class="s">"submit"</span>
      <span class="na">class=</span><span class="s">"self-start bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"</span>
    <span class="nt">&gt;</span>
      Send
    <span class="nt">&lt;/button&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>L’<code class="language-plaintext highlighter-rouge">id="new_message"</code> sul <code class="language-plaintext highlighter-rouge">div</code> che avvolge tutto è ciò che <code class="language-plaintext highlighter-rouge">turbo_stream.replace "new_message"</code> prende di mira.</p>

<h2 id="bug-1-renderizzare-i-messaggi-per-ruolo-e-un-nome-di-variabile-che-cambia-sotto-i-piedi">Bug #1: renderizzare i messaggi per ruolo, e un nome di variabile che cambia sotto i piedi</h2>

<p>La view dell’episodio 3 scorreva <code class="language-plaintext highlighter-rouge">@conversation.messages</code> a mano e diramava su <code class="language-plaintext highlighter-rouge">message.role</code> inline per scegliere lo stile. Non funziona più nel momento in cui altro codice — <code class="language-plaintext highlighter-rouge">broadcasts_to</code> — deve renderizzare un <code class="language-plaintext highlighter-rouge">Message</code> da solo, senza il ciclo della nostra view intorno. Quindi questo episodio sposta il rendering in dei partial, e <code class="language-plaintext highlighter-rouge">render @conversation.messages</code> usa la convenzione standard di Rails “un partial per record”: per un <code class="language-plaintext highlighter-rouge">Message</code>, normalmente significherebbe <code class="language-plaintext highlighter-rouge">messages/_message.html.erb</code>. Ho scritto esattamente quel partial per primo, e ho ottenuto questo:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ActionView::MissingTemplate in Chats#new
Missing partial messages/_user with {locale: [:en], formats: [:html], ...}
</code></pre></div></div>

<p>Non <code class="language-plaintext highlighter-rouge">_message</code> — <code class="language-plaintext highlighter-rouge">_user</code>. Il motivo sta nel concern <code class="language-plaintext highlighter-rouge">Message</code> di RubyLLM stesso, <a href="https://github.com/crmne/ruby_llm"><code class="language-plaintext highlighter-rouge">message_methods.rb</code></a>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">to_partial_path</span>
  <span class="n">partial_prefix</span> <span class="o">=</span> <span class="nb">self</span><span class="p">.</span><span class="nf">class</span><span class="p">.</span><span class="nf">name</span><span class="p">.</span><span class="nf">underscore</span><span class="p">.</span><span class="nf">pluralize</span>
  <span class="n">role_partial</span> <span class="o">=</span> <span class="k">if</span> <span class="n">to_llm</span><span class="p">.</span><span class="nf">tool_call?</span>
                   <span class="s1">'tool_calls'</span>
                 <span class="k">elsif</span> <span class="n">role</span><span class="p">.</span><span class="nf">to_s</span> <span class="o">==</span> <span class="s1">'tool'</span>
                   <span class="s1">'tool'</span>
                 <span class="k">else</span>
                   <span class="n">role</span><span class="p">.</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">presence</span> <span class="o">||</span> <span class="s1">'assistant'</span>
                 <span class="k">end</span>
  <span class="s2">"</span><span class="si">#{</span><span class="n">partial_prefix</span><span class="si">}</span><span class="s2">/</span><span class="si">#{</span><span class="n">role_partial</span><span class="si">}</span><span class="s2">"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>RubyLLM sovrascrive <code class="language-plaintext highlighter-rouge">to_partial_path</code> così che un <code class="language-plaintext highlighter-rouge">Message</code> scelga il proprio partial in base al ruolo — <code class="language-plaintext highlighter-rouge">messages/user</code>, <code class="language-plaintext highlighter-rouge">messages/assistant</code>, e così via. È esattamente per questo che il generator ufficiale include partial separati <code class="language-plaintext highlighter-rouge">_user.html.erb</code>, <code class="language-plaintext highlighter-rouge">_assistant.html.erb</code>, <code class="language-plaintext highlighter-rouge">_system.html.erb</code>, <code class="language-plaintext highlighter-rouge">_tool.html.erb</code> invece di uno solo generico: deve, questa sovrascrittura lo impone. Quindi la correzione è stata rinominare il file — <code class="language-plaintext highlighter-rouge">messages/_user.html.erb</code> e <code class="language-plaintext highlighter-rouge">messages/_assistant.html.erb</code> — non cambiare nulla nella chiamata di render.</p>

<p>Questo ha risolto l’errore di template mancante, ma non la pagina. Ricaricando è comparso un errore diverso:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NameError in Chats#new
undefined local variable or method 'user' for an instance of #&lt;Class:0x...&gt;
</code></pre></div></div>

<p>Avevo scritto il partial aspettandomi una locale chiamata <code class="language-plaintext highlighter-rouge">message</code> (il nome naturale, coerente con la variabile usata ovunque altrove in questa app):</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">dom_id</span><span class="p">(</span><span class="n">message</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="s">"</span> <span class="na">class=</span><span class="s">"text-right"</span><span class="nt">&gt;</span>
  ...
</code></pre></div></div>

<p>Ma quando Rails renderizza un partial risolto tramite <code class="language-plaintext highlighter-rouge">to_partial_path</code>, chiama la variabile locale come il <strong>partial</strong>, non come la classe — per <code class="language-plaintext highlighter-rouge">messages/_user.html.erb</code>, quella è <code class="language-plaintext highlighter-rouge">user</code>, non <code class="language-plaintext highlighter-rouge">message</code>. Ho corretto il nome e sono andato avanti, convinto che la storia finisse lì. Non era così.</p>

<h2 id="bug-2-lo-stesso-partial-renderizzato-in-due-modi-diversi-con-due-nomi-di-variabile-diversi">Bug #2: lo stesso partial, renderizzato in due modi diversi, con due nomi di variabile diversi</h2>

<p>Dopo quella correzione sembrava tutto a posto — finché non ho davvero inviato un messaggio e ho visto i broadcast <strong>live</strong> (quelli innescati da <code class="language-plaintext highlighter-rouge">broadcasts_to</code>, non il render iniziale della pagina) esplodere nel log del server:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Error performing Turbo::Streams::ActionBroadcastJob ...:
ActionView::Template::Error (undefined local variable or method 'user' for an instance of #&lt;Class:0x...&gt;):
app/views/messages/_user.html.erb:2
</code></pre></div></div>

<p>Stesso errore, stesso file — ma il render iniziale della pagina, pochi istanti prima nello stesso identico log, aveva funzionato bene con lo stesso identico partial. Due percorsi di codice diversi stavano renderizzando <code class="language-plaintext highlighter-rouge">messages/_user.html.erb</code> con due insiemi diversi di variabili locali. <code class="language-plaintext highlighter-rouge">render @conversation.messages</code> (una collection) deduce il nome della locale dal percorso del partial risolto — <code class="language-plaintext highlighter-rouge">user</code>. Ma il callback <code class="language-plaintext highlighter-rouge">after_update_commit</code> di <code class="language-plaintext highlighter-rouge">broadcasts_to</code> (quello innescato dall’aggiornamento incidentale di <code class="language-plaintext highlighter-rouge">content_raw</code> dentro <code class="language-plaintext highlighter-rouge">add_message</code>, menzionato sopra) chiama <code class="language-plaintext highlighter-rouge">broadcast_replace_later_to</code>, che renderizza lo stesso partial con <code class="language-plaintext highlighter-rouge">locals: {message: ...}</code> esplicito — la locale prende il nome dalla <strong>classe del modello</strong>, non dal partial.</p>

<p>Stesso partial, due chiamanti, due nomi di variabile locale diversi per lo stesso identico oggetto. La correzione è una prima riga difensiva, che legge quale delle due è effettivamente presente:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/messages/_user.html.erb %&gt;</span>
<span class="cp">&lt;%</span> <span class="n">user</span> <span class="o">=</span> <span class="n">local_assigns</span><span class="p">[</span><span class="ss">:user</span><span class="p">]</span> <span class="o">||</span> <span class="n">local_assigns</span><span class="p">[</span><span class="ss">:message</span><span class="p">]</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">dom_id</span><span class="p">(</span><span class="n">user</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="s">"</span> <span class="na">class=</span><span class="s">"text-right"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-xs text-gray-500 mb-1"</span><span class="nt">&gt;</span>user<span class="nt">&lt;/p&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">dom_id</span><span class="p">(</span><span class="n">user</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="s">_content"</span> <span class="na">class=</span><span class="s">"inline-block rounded-md px-3 py-2 bg-indigo-600 text-white"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">user</span><span class="p">.</span><span class="nf">content</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/messages/_assistant.html.erb %&gt;</span>
<span class="cp">&lt;%</span> <span class="n">assistant</span> <span class="o">=</span> <span class="n">local_assigns</span><span class="p">[</span><span class="ss">:assistant</span><span class="p">]</span> <span class="o">||</span> <span class="n">local_assigns</span><span class="p">[</span><span class="ss">:message</span><span class="p">]</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">dom_id</span><span class="p">(</span><span class="n">assistant</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="s">"</span> <span class="na">class=</span><span class="s">"text-left"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-xs text-gray-500 mb-1"</span><span class="nt">&gt;</span>assistant<span class="nt">&lt;/p&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">dom_id</span><span class="p">(</span><span class="n">assistant</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="s">_content"</span> <span class="na">class=</span><span class="s">"inline-block rounded-md px-3 py-2 bg-gray-100"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">assistant</span><span class="p">.</span><span class="nf">content</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>È esattamente la forma del fallback nel partial generato da RubyLLM stesso (<code class="language-plaintext highlighter-rouge">assistant ||= local_assigns[:message]</code>) — non ne avevo notato il motivo finché non ho sbattuto contro lo stesso muro io stesso. Leggere codice generato prima di averne bisogno e leggerlo <strong>dopo</strong> che ti ha appena rotto la pagina insegnano lezioni molto diverse; questa è stata la seconda.</p>

<p>Il contenitore di cui ha bisogno il <code class="language-plaintext highlighter-rouge">render @conversation.messages</code> iniziale, e che l’append di <code class="language-plaintext highlighter-rouge">broadcasts_to</code> alla creazione (target di default <code class="language-plaintext highlighter-rouge">model_name.plural</code>, <code class="language-plaintext highlighter-rouge">"messages"</code>) deve trovare già presente in pagina:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/chats/new.html.erb — estratto %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"messages"</span> <span class="na">class=</span><span class="s">"space-y-4 mb-8"</span><span class="nt">&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">render</span> <span class="vi">@conversation</span><span class="p">.</span><span class="nf">messages</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<h2 id="bug-3-quello-vero-due-nomi-diversi-per-lo-stesso-stream">Bug #3 (quello vero): due nomi diversi per lo stesso stream</h2>

<p>Con entrambi i partial corretti, ho ricaricato, inviato un messaggio, e — niente. Nessun errore da nessuna parte. Il form si svuotava (quindi la risposta <code class="language-plaintext highlighter-rouge">turbo_stream</code> di <code class="language-plaintext highlighter-rouge">create</code> aveva funzionato). Ma nessun fumetto utente, nessun fumetto assistant, nemmeno dopo diversi secondi, nemmeno dopo che il job in background aveva chiaramente finito (potevo vedere la risposta completa, salvata correttamente, ricaricando la pagina).</p>

<p>Il log di Rails raccontava una storia <strong>completamente pulita</strong>: il job girava, <code class="language-plaintext highlighter-rouge">conversation.ask</code> creava entrambe le righe, ogni <code class="language-plaintext highlighter-rouge">Turbo::Streams::ActionBroadcastJob</code> veniva eseguito con successo, e riga dopo riga di <code class="language-plaintext highlighter-rouge">[ActionCable] Broadcasting to conversation_7: ...</code> mostrava ogni singolo chunk uscire, fino all’ultimissimo. Lato server, tutto funzionava. Il browser semplicemente non riceveva mai nulla.</p>

<p>Ho controllato la pagina stessa con un po’ di JavaScript, dalla console del browser:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">turbo-cable-stream-source</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">el</span><span class="p">.</span><span class="nf">hasAttribute</span><span class="p">(</span><span class="dl">'</span><span class="s1">connected</span><span class="dl">'</span><span class="p">)</span>   <span class="c1">// true</span>
<span class="nx">el</span><span class="p">.</span><span class="nx">subscription</span>                <span class="c1">// presente</span>
</code></pre></div></div>

<p>Connesso, sottoscritto, nessun errore in console — e ancora niente in arrivo. A questo punto le due parti sembravano individualmente corrette e reciprocamente irraggiungibili, che è esattamente ciò che succede quando ciascuna sta ascoltando / trasmettendo su uno stream <strong>diverso</strong> che appare semplicemente uguale a chi legge il codice.</p>

<p>La sottoscrizione vive nella view:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">turbo_stream_from</span> <span class="vi">@conversation</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>L’avevo scritta come la scriveresti se avessi visto <code class="language-plaintext highlighter-rouge">turbo_stream_from</code> usato solo con un semplice oggetto ActiveRecord — passi il record, fatto. Ed è <strong>valido</strong> — <code class="language-plaintext highlighter-rouge">turbo_stream_from</code> accetta qualsiasi “streamable”, incluso un’istanza di modello, e ne deriva un nome di stream (basato sul suo GlobalID). Il problema è che <code class="language-plaintext highlighter-rouge">@conversation</code> non era mai l’identificatore usato in nessun altro punto di questo episodio. <code class="language-plaintext highlighter-rouge">broadcasts_to</code> e <code class="language-plaintext highlighter-rouge">broadcast_append_chunk</code>, nel modello <code class="language-plaintext highlighter-rouge">Message</code>, costruiscono entrambi il nome dello stream da una semplice stringa:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s2">"conversation_</span><span class="si">#{</span><span class="n">message</span><span class="p">.</span><span class="nf">conversation_id</span><span class="si">}</span><span class="s2">"</span>
</code></pre></div></div>

<p>Una stringa e un oggetto modello non producono lo stesso nome di stream firmato solo perché un umano li legge come “la stessa conversazione”. <code class="language-plaintext highlighter-rouge">turbo_stream_from @conversation</code> e <code class="language-plaintext highlighter-rouge">broadcasts_to -&gt;(message) { "conversation_#{message.conversation_id}" }</code> stavano silenziosamente sottoscrivendosi e trasmettendo su due canali completamente diversi — nessun errore su nessuno dei due lati, perché nessuno dei due sbaglia nulla isolatamente; semplicemente non si incontrano mai.</p>

<p>Il template della view del generator stesso me l’ha chiarito — <a href="https://github.com/crmne/ruby_llm"><code class="language-plaintext highlighter-rouge">chats/show.html.erb.tt</code></a>:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%%</span><span class="o">=</span> <span class="n">turbo_stream_from</span> <span class="s2">"&lt;%= chat_variable_name </span><span class="cp">%&gt;</span>_#{@<span class="cp">&lt;%=</span><span class="s2"> chat_variable_name </span><span class="cp">%&gt;</span>.id}" %&gt;
</code></pre></div></div>

<p>Una stringa, costruita nello stesso modo di quella del modello. Allineandomi:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">turbo_stream_from</span> <span class="s2">"conversation_</span><span class="si">#{</span><span class="vi">@conversation</span><span class="p">.</span><span class="nf">id</span><span class="si">}</span><span class="s2">"</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>Una riga, e ogni broadcast che stava andando silenziosamente a vuoto ha iniziato ad arrivare all’istante. La lezione sotto il bug: <code class="language-plaintext highlighter-rouge">turbo_stream_from</code> e <code class="language-plaintext highlighter-rouge">broadcasts_to</code> non devono per forza concordare su <strong>come</strong> dai un nome a uno stream — oggetto o stringa, non importa quale — ma ogni sottoscrittore e ogni trasmettitore devono assolutamente concordare tra loro, esattamente, perché non c’è un percorso di errore quando non lo fanno. Fallisce restando in silenzio, non sollevando un’eccezione.</p>

<p>La view completa e funzionante:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/chats/new.html.erb %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"max-w-xl mx-auto mt-16 px-4"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"flex items-center justify-between mb-6"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"text-2xl font-semibold"</span><span class="nt">&gt;</span>RubyLLM chat demo<span class="nt">&lt;/h1&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">button_to</span> <span class="s2">"New conversation"</span><span class="p">,</span> <span class="n">chat_path</span><span class="p">,</span> <span class="ss">method: :delete</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"text-sm text-gray-500 hover:text-gray-700"</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>

  <span class="cp">&lt;%=</span> <span class="n">turbo_stream_from</span> <span class="s2">"conversation_</span><span class="si">#{</span><span class="vi">@conversation</span><span class="p">.</span><span class="nf">id</span><span class="si">}</span><span class="s2">"</span> <span class="cp">%&gt;</span>

  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"messages"</span> <span class="na">class=</span><span class="s">"space-y-4 mb-8"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">render</span> <span class="vi">@conversation</span><span class="p">.</span><span class="nf">messages</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>

  <span class="cp">&lt;%=</span> <span class="n">render</span> <span class="s2">"form"</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<h2 id="provarlo-nel-browser">Provarlo nel browser</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Apri <code class="language-plaintext highlighter-rouge">http://127.0.0.1:3000</code>, chiedi qualcosa, e questa volta la risposta non appare semplicemente — si riempie progressivamente, come ci si aspetta da un’app di chat. Ecco uno scambio reale catturato durante i test di questo episodio:</p>

<p><img src="/assets/images/ai-with-ruby-ep4-chat-pep-talk.png" alt="Due scambi domanda/risposta nella demo di chat: si chiede di spiegare Turbo Streams a un bambino di cinque anni, poi un incoraggiamento di due righe per fare debug di ActionCable a mezzanotte — la risposta menziona di controllare che &quot;il nome dello stream corrisponda esattamente&quot;, che è esattamente il bug raccontato in questo episodio." /></p>

<p><em>Non pianificato, ma calzante: ho chiesto un incoraggiamento sul debug di ActionCable, e la risposta è atterrata su “controlla che il nome dello stream corrisponda esattamente” — esattamente il bug della sezione qui sopra.</em></p>

<p>Una sessione più lunga, che mostra il link “New conversation” (in alto a destra) che chiude il TODO dell’episodio 3, e più scambi accumulati nella stessa conversazione:</p>

<p><img src="/assets/images/ai-with-ruby-ep4-chat-full.png" alt="La stessa demo di chat dopo un terzo scambio: una breve storia su un messaggio WebSocket che viaggia dal server al browser, con tutte e tre le coppie domanda/risposta impilate in ordine." /></p>

<h2 id="un-avvertimento-da-conoscere-lordine-dei-broadcast-non-è-garantito">Un avvertimento da conoscere: l’ordine dei broadcast non è garantito</h2>

<p>Mentre catturavo quella seconda schermata, mi sono imbattuto in qualcosa che vale la pena segnalare invece di sistemare silenziosamente. Il callback <code class="language-plaintext highlighter-rouge">after_create_commit</code> di <code class="language-plaintext highlighter-rouge">broadcasts_to</code> non trasmette in modo sincrono — chiama <code class="language-plaintext highlighter-rouge">broadcast_action_later_to</code>, che arruola <strong>un altro</strong> job in background (<code class="language-plaintext highlighter-rouge">Turbo::Streams::ActionBroadcastJob</code>) per fare il rendering e il broadcast effettivi. Quindi una singola chiamata a <code class="language-plaintext highlighter-rouge">.ask</code>, sotto il cofano, arruola diversi job in rapida successione: uno per appendere il messaggio utente, uno (dall’aggiornamento incidentale di <code class="language-plaintext highlighter-rouge">content_raw</code>) per sostituirlo di nuovo, uno per appendere il messaggio assistant vuoto, più uno finale per sostituirlo con il testo finito.</p>

<p>Con l’adapter di coda predefinito di Rails, <code class="language-plaintext highlighter-rouge">:async</code> — un piccolo pool di thread nello stesso processo — questi non hanno la garanzia stretta di finire nell’ordine in cui sono stati arruolati. In un test dal vivo ho visto la risposta di un assistant renderizzarsi <strong>prima</strong> della domanda dell’utente a cui stava rispondendo, semplicemente perché quel job di append è capitato a finire su un thread diverso qualche millisecondo prima. Ricaricare la pagina subito dopo mostrava l’ordine corretto — i messaggi vengono sempre recuperati con <code class="language-plaintext highlighter-rouge">ORDER BY created_at</code>, quindi il database non sbaglia mai, solo una specifica pagina che si aggiorna dal vivo può momentaneamente mostrare le cose fuori sequenza. In produzione, con una coda vera (Solid Queue, già configurata in questa app) è molto meno probabile che sia visibile alla normale velocità di scrittura e lettura, ma non è nemmeno strutturalmente impossibile. Non è qualcosa che questo episodio risolve — solo qualcosa che vale la pena sapere che esiste, nel caso un messaggio sembri mai saltare la coda sullo schermo.</p>

<h2 id="cosa-viene-dopo">Cosa viene dopo</h2>

<p>L’episodio 5 copre la ricerca semantica: trasformare il contenuto dei <code class="language-plaintext highlighter-rouge">Message</code> in embedding, e permettere a un utente di cercare tra le conversazioni passate per significato, non solo per parole esatte.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Nell’episodio 3 le conversazioni avevano iniziato a sopravvivere a un refresh della pagina, ma la richiesta in sé restava una scatola nera: premi Invia, e l’intera pagina resta lì ferma — nessuno spinner, niente — finché l’intera risposta non torna dal modello, e solo allora la pagina reindirizza e la mostra, tutta insieme. Per una risposta di una frase sono uno o due secondi di nulla. Per una più lunga, possono essere cinque, sei secondi di una pagina che sembra bloccata.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-ep4-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-ep4-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="it"><title type="html">WhatsApp con Rails: inviare un messaggio vero via Twilio</title><link href="https://antoninoscaffidi.github.io/it/whatsapp-rails-sending-via-twilio/" rel="alternate" type="text/html" title="WhatsApp con Rails: inviare un messaggio vero via Twilio" /><published>2026-08-15T07:00:00+00:00</published><updated>2026-08-15T07:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/whatsapp-rails-sending-via-twilio.it</id><content type="html" xml:base="https://antoninoscaffidi.github.io/whatsapp-rails-sending-via-twilio/"><![CDATA[<p>L’<a href="/it/whatsapp-rails-setup-and-contacts/">episodio 1</a> ci ha dato un modello <code class="language-plaintext highlighter-rouge">Contact</code> e un form per aggiungere persone. Nulla parlava ancora con Twilio. Questo episodio chiude quel vuoto dall’inizio alla fine: un form per comporre un messaggio, una vera chiamata API, e un messaggio che arriva davvero su un vero telefono via WhatsApp — cosa che ho testato per davvero scrivendo questo post, incluso il modo in cui fallisce.</p>

<p>Il codice è taggato <a href="https://github.com/AntoninoScaffidi/whatsapp-with-rails/tree/episode-2"><code class="language-plaintext highlighter-rouge">episode-2</code></a> nel repo <a href="https://github.com/AntoninoScaffidi/whatsapp-with-rails">whatsapp-with-rails</a>. Questo è un post lungo — l’obiettivo è non lasciare nulla senza spiegazione: ogni gemma, ogni riga di ogni migrazione, ogni riga del modello e del controller, e gli errori esatti in cui incapperai e perché.</p>

<h2 id="cosè-davvero-la-sandbox-whatsapp-e-perché-serve">Cos’è davvero la Sandbox WhatsApp, e perché serve</h2>

<p>Mandare un messaggio WhatsApp attraverso la vera WhatsApp Business Platform richiede un numero di telefono registrato e approvato tramite Meta — un processo con passaggi di revisione e tempi di attesa. La <strong>Sandbox</strong> di Twilio esiste apposta per non dover passare da lì solo per scrivere e testare codice. È un numero Twilio condiviso e già approvato (<code class="language-plaintext highlighter-rouge">+14155238886</code> per chiunque usi la sandbox di Twilio) che può mandare e ricevere messaggi WhatsApp immediatamente, con una restrizione: parlerà solo con numeri di telefono che si sono <strong>uniti</strong> esplicitamente.</p>

<p>Unirsi significa mandare un messaggio specifico — <code class="language-plaintext highlighter-rouge">join &lt;codice-di-due-parole&gt;</code>, es. <code class="language-plaintext highlighter-rouge">join vowel-purpose</code>, un codice che Twilio genera per account — da WhatsApp, dal numero di telefono con cui vuoi fare i test, a quel numero sandbox. Puoi farlo a mano da WhatsApp, oppure aprendo il link/QR code che la console di Twilio ti mostra (Console → Messaging → Try it out → Send a WhatsApp message). Una volta che un numero si è unito, Twilio può mandargli messaggi via API; un numero che non si è mai unito li rifiuterà, sempre, con un errore che vedremo più avanti.</p>

<p>Due dettagli da sapere, perché prima o poi ci sbatterai contro entrambi:</p>

<ul>
  <li><strong>L’adesione dura 3 giorni di inattività</strong>, non per sempre. Se nessuno manda nulla alla sandbox da quel numero per 3 giorni, deve rifare il <code class="language-plaintext highlighter-rouge">join</code>.</li>
  <li><strong>Questo è strettamente un meccanismo di test.</strong> In produzione mandi da un mittente abilitato per WhatsApp che possiedi (configurato comunque tramite Twilio, ma senza la restrizione “solo numeri già uniti”) — la sandbox serve per lo sviluppo, non per parlare con clienti veri.</li>
</ul>

<h2 id="le-due-gemme">Le due gemme</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"twilio-ruby"</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">group</span> <span class="ss">:development</span> <span class="k">do</span>
  <span class="n">gem</span> <span class="s2">"web-console"</span>
  <span class="n">gem</span> <span class="s2">"dotenv-rails"</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">twilio-ruby</code> è il client Ruby ufficiale di Twilio — un wrapper attorno all’API REST di Twilio, che ti dà <code class="language-plaintext highlighter-rouge">Twilio::REST::Client.new(...).messages.create(...)</code> invece di costruire richieste HTTP a mano e fare il parsing del JSON. <code class="language-plaintext highlighter-rouge">dotenv-rails</code>, come nella <a href="/it/wiring-rubyllm-into-rails/">serie ai-with-ruby</a>, carica un file <code class="language-plaintext highlighter-rouge">.env</code> dentro <code class="language-plaintext highlighter-rouge">ENV</code> in development, così le credenziali vivono fuori dal codice.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">install</span>
</code></pre></div></div>

<h2 id="credenziali-env-envexample-e-gitignore">Credenziali: <code class="language-plaintext highlighter-rouge">.env</code>, <code class="language-plaintext highlighter-rouge">.env.example</code> e <code class="language-plaintext highlighter-rouge">.gitignore</code></h2>

<p>Servono tre valori reali, tutti segreti: il tuo Account SID Twilio, il tuo Auth Token, e il numero sandbox WhatsApp. Il <code class="language-plaintext highlighter-rouge">.gitignore</code> di default di Rails 8 esclude già <code class="language-plaintext highlighter-rouge">.env*</code>, quindi un vero file <code class="language-plaintext highlighter-rouge">.env</code> non viene mai committato. Committiamo invece un modello:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># .env.example
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your-auth-token-here
TWILIO_WHATSAPP_NUMBER=+14155238886
</code></pre></div></div>

<p>Siccome il pattern <code class="language-plaintext highlighter-rouge">/.env*</code> del <code class="language-plaintext highlighter-rouge">.gitignore</code> è abbastanza ampio da intercettare anche <code class="language-plaintext highlighter-rouge">.env.example</code>, serve un’eccezione esplicita — esattamente la stessa trappola già incontrata nella serie ai-with-ruby:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># .gitignore
/.env*
!/.env.example
</code></pre></div></div>

<p>Per far girare davvero l’app, copia il modello e inserisci i valori reali:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cp</span> .env.example .env
</code></pre></div></div>

<p><strong>Sull’Account SID e l’Auth Token in particolare</strong>: sono le credenziali master del tuo account Twilio — chiunque le abbia può mandare messaggi (e farteli addebitare) a nome tuo. Si trovano sulla dashboard principale della Twilio Console appena fai login. Se mai ne incolli una per sbaglio da qualche parte pubblica, Twilio ti permette di rigenerare l’Auth Token dalla console; quello vecchio smette di funzionare immediatamente.</p>

<h2 id="il-client-twilio-un-initializer-una-riga">Il client Twilio: un initializer, una riga</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/twilio.rb</span>
<span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">config</span><span class="p">.</span><span class="nf">x</span><span class="p">.</span><span class="nf">twilio_client</span> <span class="o">=</span> <span class="no">Twilio</span><span class="o">::</span><span class="no">REST</span><span class="o">::</span><span class="no">Client</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span>
  <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"TWILIO_ACCOUNT_SID"</span><span class="p">),</span>
  <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"TWILIO_AUTH_TOKEN"</span><span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Stessa logica dell’initializer RubyLLM nella serie AI with Ruby: i file dentro <code class="language-plaintext highlighter-rouge">config/initializers/</code> vengono eseguiti una sola volta, all’avvio, prima di qualsiasi richiesta — il posto giusto per costruire qualcosa che parla con un’API di terze parti e passargli le tue credenziali.</p>

<p>Due cose su cui vale la pena essere precisi. Primo, <code class="language-plaintext highlighter-rouge">ENV.fetch("TWILIO_ACCOUNT_SID")</code> — nessun default dato come secondo argomento — <strong>solleva un errore</strong> se la variabile manca, invece di proseguire silenziosamente con <code class="language-plaintext highlighter-rouge">nil</code>. È voluto: un client Twilio mal configurato che non fa nulla in silenzio è molto più difficile da debuggare di un’app che si rifiuta di avviarsi con un chiaro <code class="language-plaintext highlighter-rouge">KeyError</code>. Secondo, <code class="language-plaintext highlighter-rouge">Rails.application.config.x</code> è il namespace integrato di Rails per la configurazione personalizzata dell’applicazione — la <code class="language-plaintext highlighter-rouge">x</code> sta per “custom”, ed esiste apposta perché non si sia tentati di infilare configurazione specifica dell’app in costanti globali o direttamente in <code class="language-plaintext highlighter-rouge">Rails.application.config</code> (che Rails stesso usa). Ovunque nell’app, <code class="language-plaintext highlighter-rouge">Rails.application.config.x.twilio_client</code> restituisce lo stesso client configurato.</p>

<h2 id="il-modello-message-e-la-migrazione-dietro-di-esso">Il modello Message, e la migrazione dietro di esso</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate model Message contact:references body:text twilio_sid:string status:string
</code></pre></div></div>

<p>Il generator ha prodotto una migrazione che poi ho modificato prima di eseguirla — vale la pena guardare entrambe le versioni, perché la modifica è dove sta il ragionamento vero.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_messages.rb — come generata</span>
<span class="n">create_table</span> <span class="ss">:messages</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:contact</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">text</span> <span class="ss">:body</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:twilio_sid</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:status</span>

  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_messages.rb — come eseguita</span>
<span class="n">create_table</span> <span class="ss">:messages</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:contact</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">text</span> <span class="ss">:body</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:twilio_sid</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:status</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">default: </span><span class="s2">"queued"</span>

  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Colonna per colonna:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">t.references :contact, null: false, foreign_key: true</code></strong> — è ciò che l’argomento <code class="language-plaintext highlighter-rouge">contact:references</code> passato al generator produce da solo: una colonna intera <code class="language-plaintext highlighter-rouge">contact_id</code>, un indice a livello di database su di essa, un vincolo di chiave esterna a livello di database (<code class="language-plaintext highlighter-rouge">foreign_key: true</code>) così il database stesso rifiuta di far puntare un <code class="language-plaintext highlighter-rouge">Message</code> a un <code class="language-plaintext highlighter-rouge">Contact</code> che non esiste, e <code class="language-plaintext highlighter-rouge">null: false</code> perché un messaggio senza contatto non ha senso.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">t.text :body, null: false</code></strong> — <code class="language-plaintext highlighter-rouge">text</code> invece di <code class="language-plaintext highlighter-rouge">string</code> perché il corpo di un messaggio non ha un limite di lunghezza naturale e breve come un nome; ho aggiunto <code class="language-plaintext highlighter-rouge">null: false</code> a mano perché il generator non aggiunge da solo vincoli di presenza, e un messaggio vuoto non è mai qualcosa che vogliamo inviare.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">t.string :twilio_sid</code></strong> — volutamente nullable. Viene valorizzato <em>dopo</em> che Twilio accetta il messaggio (approfondito sotto); prima di quel momento, esiste già una riga <code class="language-plaintext highlighter-rouge">Message</code> reale senza ancora un SID.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">t.string :status, null: false, default: "queued"</code></strong> — l’unica modifica sostanziale. Ho aggiunto <code class="language-plaintext highlighter-rouge">null: false, default: "queued"</code> così ogni <code class="language-plaintext highlighter-rouge">Message</code> ha uno status sensato nell’istante in cui viene creato, prima ancora di aver parlato con Twilio. Perché questa colonna esista affatto è la domanda più interessante, coperta nella prossima sezione.</li>
</ul>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/message.rb</span>
<span class="k">class</span> <span class="nc">Message</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:contact</span>

  <span class="n">validates</span> <span class="ss">:body</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>

  <span class="k">def</span> <span class="nf">deliver!</span>
    <span class="n">twilio_message</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">config</span><span class="p">.</span><span class="nf">x</span><span class="p">.</span><span class="nf">twilio_client</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span>
      <span class="ss">from: </span><span class="s2">"whatsapp:</span><span class="si">#{</span><span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s1">'TWILIO_WHATSAPP_NUMBER'</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span>
      <span class="ss">to: </span><span class="s2">"whatsapp:</span><span class="si">#{</span><span class="n">contact</span><span class="p">.</span><span class="nf">whatsapp_number</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span>
      <span class="ss">body: </span><span class="n">body</span>
    <span class="p">)</span>

    <span class="n">update!</span><span class="p">(</span><span class="ss">twilio_sid: </span><span class="n">twilio_message</span><span class="p">.</span><span class="nf">sid</span><span class="p">,</span> <span class="ss">status: </span><span class="n">twilio_message</span><span class="p">.</span><span class="nf">status</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">belongs_to :contact</code> da solo basta a richiedere un contatto — Rails 5+ rende le associazioni <code class="language-plaintext highlighter-rouge">belongs_to</code> obbligatorie di default, quindi questa sola riga ci dà già gran parte di quello che <code class="language-plaintext highlighter-rouge">null: false</code> nella migrazione faceva a livello di database, ma a livello applicativo.</p>

<p><code class="language-plaintext highlighter-rouge">validates :body, presence: true</code> rispecchia il <code class="language-plaintext highlighter-rouge">null: false</code> su <code class="language-plaintext highlighter-rouge">body</code> — uno è il database che rifiuta dati non validi indipendentemente da cosa li inserisce, l’altro è un errore di validazione amichevole prima ancora di provare, ed è ciò che permette a <code class="language-plaintext highlighter-rouge">MessagesController</code> di mostrare “can’t be blank” nel form invece di un crash a livello di database.</p>

<p><code class="language-plaintext highlighter-rouge">deliver!</code> è l’unico metodo che fa davvero qualcosa. Guarda bene <code class="language-plaintext highlighter-rouge">from:</code> e <code class="language-plaintext highlighter-rouge">to:</code> — i numeri WhatsApp nell’API di Twilio hanno sempre il prefisso letterale <code class="language-plaintext highlighter-rouge">whatsapp:</code>, es. <code class="language-plaintext highlighter-rouge">whatsapp:+14155238886</code>. È così che l’API di messaggistica unificata di Twilio distingue tra mandare lo stesso numero come messaggio WhatsApp o come semplice SMS — il prefisso, non un endpoint separato. Dimenticarlo su uno dei due lati fa fallire la chiamata o, silenziosamente, prova a mandare un normale SMS invece.</p>

<p>La convenzione di naming — <code class="language-plaintext highlighter-rouge">deliver!</code>, con il punto esclamativo — rispecchia la convenzione di Rails stesso per i metodi che fanno qualcosa con effetti collaterali reali e potenzialmente fallibili (<code class="language-plaintext highlighter-rouge">save!</code>, <code class="language-plaintext highlighter-rouge">create!</code>), a differenza di una semplice query. Mandare un messaggio WhatsApp è un effetto collaterale reale quanto un metodo può averne.</p>

<h2 id="perché-esistono-twilio_sid-e-status-linvio-whatsapp-è-asincrono">Perché esistono <code class="language-plaintext highlighter-rouge">twilio_sid</code> e <code class="language-plaintext highlighter-rouge">status</code>: l’invio WhatsApp è asincrono</h2>

<p>Vale la pena essere espliciti su questo, perché è facile dare per scontato che una chiamata API riuscita significhi che il messaggio è arrivato — non è così. Quando <code class="language-plaintext highlighter-rouge">messages.create</code> ritorna senza sollevare errori, significa solo: <em>Twilio ha accettato la richiesta e l’ha messa in coda per la consegna.</em> Lo <code class="language-plaintext highlighter-rouge">status</code> che Twilio restituisce a quel punto è tipicamente <code class="language-plaintext highlighter-rouge">"queued"</code>, non <code class="language-plaintext highlighter-rouge">"delivered"</code>. Cosa succede davvero al messaggio dopo — inviato, consegnato, letto, o fallito — avviene in modo asincrono, e per default la tua app Rails non ne sa più nulla a meno che non lo chieda.</p>

<p>Ecco tutto il motivo per cui <code class="language-plaintext highlighter-rouge">twilio_sid</code> e <code class="language-plaintext highlighter-rouge">status</code> sono colonne su <code class="language-plaintext highlighter-rouge">Message</code> invece di essere buttate via dopo la chiamata API: <code class="language-plaintext highlighter-rouge">twilio_sid</code> è l’identificatore che Twilio restituisce (<code class="language-plaintext highlighter-rouge">SM...</code>), ed è come recuperare quel messaggio più tardi per controllare cosa gli è successo davvero. È esattamente ciò che ho fatto testando questo episodio:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">fresh</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">config</span><span class="p">.</span><span class="nf">x</span><span class="p">.</span><span class="nf">twilio_client</span><span class="p">.</span><span class="nf">messages</span><span class="p">(</span><span class="n">message</span><span class="p">.</span><span class="nf">twilio_sid</span><span class="p">).</span><span class="nf">fetch</span>
<span class="n">fresh</span><span class="p">.</span><span class="nf">status</span>        <span class="c1"># =&gt; "failed" — aggiornato in seguito, non dalla risposta originale</span>
<span class="n">fresh</span><span class="p">.</span><span class="nf">error_code</span>     <span class="c1"># =&gt; 63015</span>
</code></pre></div></div>

<p>Il modo <em>giusto</em> per tenere <code class="language-plaintext highlighter-rouge">status</code> aggiornato senza fare polling a mano è un <strong>webhook di status callback</strong> — un URL che dai a Twilio, a cui fa una POST ogni volta che lo status di un messaggio cambia. È genuinamente più macchina (un endpoint pubblico, una rotta, la verifica della richiesta) di quanto stia in un episodio sul primo invio riuscito — quindi resta fuori scope qui, ma vale la pena sapere che <code class="language-plaintext highlighter-rouge">status</code> esiste su questo modello proprio perché quel futuro webhook avrà qualcosa da aggiornare.</p>

<h2 id="collegarlo-allapp-rotte-controller-elenco-contatti">Collegarlo all’app: rotte, controller, elenco contatti</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resources</span> <span class="ss">:contacts</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:index</span><span class="p">,</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span> <span class="k">do</span>
  <span class="n">resources</span> <span class="ss">:messages</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Le risorse annidate qui non sono decorazione — <code class="language-plaintext highlighter-rouge">messages</code> genuinamente non ha senso senza un <code class="language-plaintext highlighter-rouge">contact</code> di contesto; non esiste una schermata “componi un messaggio” che non riguardi già una persona specifica. Questo genera path come <code class="language-plaintext highlighter-rouge">new_contact_message_path(contact)</code> e <code class="language-plaintext highlighter-rouge">contact_messages_path(contact)</code>, entrambi con l’id del contatto.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/messages_controller.rb</span>
<span class="k">class</span> <span class="nc">MessagesController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">before_action</span> <span class="ss">:set_contact</span>

  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@message</span> <span class="o">=</span> <span class="vi">@contact</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">new</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@message</span> <span class="o">=</span> <span class="vi">@contact</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">message_params</span><span class="p">)</span>

    <span class="k">if</span> <span class="vi">@message</span><span class="p">.</span><span class="nf">save</span>
      <span class="k">begin</span>
        <span class="vi">@message</span><span class="p">.</span><span class="nf">deliver!</span>
        <span class="n">redirect_to</span> <span class="n">contacts_path</span><span class="p">,</span> <span class="ss">notice: </span><span class="s2">"Message sent to </span><span class="si">#{</span><span class="vi">@contact</span><span class="p">.</span><span class="nf">name</span><span class="si">}</span><span class="s2">."</span>
      <span class="k">rescue</span> <span class="no">Twilio</span><span class="o">::</span><span class="no">REST</span><span class="o">::</span><span class="no">RestError</span> <span class="o">=&gt;</span> <span class="n">e</span>
        <span class="n">redirect_to</span> <span class="n">contacts_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Twilio couldn't send the message: </span><span class="si">#{</span><span class="n">e</span><span class="p">.</span><span class="nf">error_message</span><span class="si">}</span><span class="s2">"</span>
      <span class="k">end</span>
    <span class="k">else</span>
      <span class="n">render</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">status: :unprocessable_entity</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">set_contact</span>
    <span class="vi">@contact</span> <span class="o">=</span> <span class="no">Contact</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:contact_id</span><span class="p">])</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">message_params</span>
    <span class="n">params</span><span class="p">.</span><span class="nf">require</span><span class="p">(</span><span class="ss">:message</span><span class="p">).</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:body</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">before_action :set_contact</code> gira prima di entrambe le azioni, caricando <code class="language-plaintext highlighter-rouge">@contact</code> da <code class="language-plaintext highlighter-rouge">params[:contact_id]</code> — il parametro della rotta annidata, non <code class="language-plaintext highlighter-rouge">:id</code> (che sarebbe l’id del messaggio stesso, che non esiste ancora per <code class="language-plaintext highlighter-rouge">new</code>/<code class="language-plaintext highlighter-rouge">create</code>).</p>

<p><code class="language-plaintext highlighter-rouge">create</code> fa tre cose in ordine: costruisce il <code class="language-plaintext highlighter-rouge">Message</code> (non ancora salvato), lo salva nel database, <em>poi</em> tenta la consegna. Questo ordine conta — il messaggio esiste come riga di database, con <code class="language-plaintext highlighter-rouge">status: "queued"</code> dal default della colonna, ancora prima di sapere se Twilio lo accetterà. Se il processo si interrompesse tra il salvataggio e l’invio, resterebbe comunque una traccia di un messaggio previsto, non il silenzio.</p>

<h2 id="il-caso-di-fallimento-sincrono-twiliorestresterror">Il caso di fallimento sincrono: <code class="language-plaintext highlighter-rouge">Twilio::REST::RestError</code></h2>

<p>Ecco una cosa che ho scoperto solo rompendola davvero durante i test: <code class="language-plaintext highlighter-rouge">deliver!</code> può fallire in due modi completamente diversi, e solo uno dei due è visibile dove te lo aspetteresti.</p>

<p>Il <strong>fallimento asincrono</strong> — il numero non si è mai unito alla sandbox, il messaggio viene rifiutato più a valle — non solleva nulla in Rails. <code class="language-plaintext highlighter-rouge">messages.create</code> ritorna con successo con <code class="language-plaintext highlighter-rouge">status: "queued"</code>; il fallimento emerge solo più tardi se torni a controllare, esattamente come descritto sopra. Mi è capitato direttamente: mandare a un contatto il cui numero non aveva mai inviato <code class="language-plaintext highlighter-rouge">join &lt;codice&gt;</code> alla sandbox è tornato come una normale risposta <code class="language-plaintext highlighter-rouge">queued</code> che non solleva errori dal punto di vista dell’app, e solo dopo, recuperando il messaggio da Twilio, si è rivelato <code class="language-plaintext highlighter-rouge">status: "failed"</code>, <code class="language-plaintext highlighter-rouge">error_code: 63015</code>. <strong>L’errore 63015, in particolare, significa “questo destinatario non si è unito a questa sandbox”</strong> — la cosa più comune in assoluto in cui incapperai testando questa integrazione, e vale la pena riconoscerla a colpo d’occhio.</p>

<p>Il <strong>fallimento sincrono</strong> è diverso: credenziali sbagliate, una richiesta malformata, qualsiasi cosa che l’API di Twilio rifiuti immediatamente. Quello <em>solleva</em> davvero un errore, come <code class="language-plaintext highlighter-rouge">Twilio::REST::RestError</code> — l’ho confermato direttamente, costruendo deliberatamente un client con Account SID e Auth Token sbagliati e osservandolo sollevare l’errore sulla chiamata API:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">bad_client</span> <span class="o">=</span> <span class="no">Twilio</span><span class="o">::</span><span class="no">REST</span><span class="o">::</span><span class="no">Client</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="s2">"AC0000000000000000000000000000000"</span><span class="p">,</span> <span class="s2">"wrongtoken"</span><span class="p">)</span>
<span class="n">bad_client</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="ss">from: </span><span class="s2">"whatsapp:+14155238886"</span><span class="p">,</span> <span class="ss">to: </span><span class="s2">"whatsapp:+391234567890"</span><span class="p">,</span> <span class="ss">body: </span><span class="s2">"test"</span><span class="p">)</span>
<span class="c1"># solleva Twilio::REST::RestError</span>
<span class="c1"># e.status_code    =&gt; 401</span>
<span class="c1"># e.error_message  =&gt; "Authentication Error - invalid username"</span>
</code></pre></div></div>

<p>Prima di aggiungere il <code class="language-plaintext highlighter-rouge">rescue</code> che vedi nel controller sopra, questa classe di errore sarebbe risalita direttamente attraverso <code class="language-plaintext highlighter-rouge">deliver!</code>, attraverso <code class="language-plaintext highlighter-rouge">create</code>, in un <code class="language-plaintext highlighter-rouge">500</code> non gestito, in quella che dovrebbe essere una demo da mostrare a un cliente. <code class="language-plaintext highlighter-rouge">rescue Twilio::REST::RestError =&gt; e</code> lo cattura e reindirizza con un <code class="language-plaintext highlighter-rouge">alert</code> leggibile invece — <code class="language-plaintext highlighter-rouge">e.error_message</code> è la stringa comprensibile che Twilio stesso restituisce, quindi il messaggio mostrato è la spiegazione di Twilio, non una nostra supposizione.</p>

<p>Quello che questo episodio <em>non</em> fa è trasformare il caso asincrono del 63015 in un bel messaggio in UI — non è possibile dall’interno di <code class="language-plaintext highlighter-rouge">create</code> in alcun modo, dato che l’app ha già ricevuto una risposta “riuscita”, <code class="language-plaintext highlighter-rouge">queued</code>, nel momento in cui avviene il fallimento vero. Gestirlo correttamente richiede il webhook di status callback menzionato sopra; lo segnalo qui perché sia chiaro che è un vuoto noto e voluto, non una svista.</p>

<h2 id="provarlo">Provarlo</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Aggiungi un contatto con un numero WhatsApp che si è davvero unito alla tua sandbox (vedi le istruzioni di join più sopra in questo post — un numero che non si è unito accetterà l’invio dal punto di vista dell’app e poi fallirà in modo invisibile, esattamente come descritto sopra). Clicca “Message” accanto a lui, scrivi qualcosa, invia.</p>

<p>Ecco il risultato reale dai test fatti per questo post — un messaggio vero, mandato da questo stesso codice, ricevuto su un telefono vero:</p>

<blockquote>
  <p><strong>Twilio:</strong> Test from whatsapp-with-rails episode 2 🎉</p>
</blockquote>

<p>Se provi a mandare a un contatto il cui numero non si è mai unito alla sandbox, otterrai <code class="language-plaintext highlighter-rouge">queued</code> nell’app e nulla sul telefono — è l’errore 63015 che aspetta di essere scoperto se controlli lo status del messaggio in seguito, non un bug in questo codice.</p>

<h2 id="cosa-viene-dopo">Cosa viene dopo</h2>

<p>L’episodio 3 fa lo stesso lavoro in un modo diverso: chiamando direttamente l’API Meta WhatsApp Cloud, senza Twilio in mezzo, così il compromesso tra i due approcci — la semplicità di Twilio contro un servizio in meno nel mezzo — si vede nel codice vero, non solo in astratto.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[L’episodio 1 ci ha dato un modello Contact e un form per aggiungere persone. Nulla parlava ancora con Twilio. Questo episodio chiude quel vuoto dall’inizio alla fine: un form per comporre un messaggio, una vera chiamata API, e un messaggio che arriva davvero su un vero telefono via WhatsApp — cosa che ho testato per davvero scrivendo questo post, incluso il modo in cui fallisce.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/whatsapp-with-rails-ep2-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/whatsapp-with-rails-ep2-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="it"><title type="html">VicinoTe: autenticazione con il generator integrato di Rails 8</title><link href="https://antoninoscaffidi.github.io/it/vicinote-authentication-with-rails-8/" rel="alternate" type="text/html" title="VicinoTe: autenticazione con il generator integrato di Rails 8" /><published>2026-08-13T07:00:00+00:00</published><updated>2026-08-13T07:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/vicinote-authentication-with-rails-8.it</id><content type="html" xml:base="https://antoninoscaffidi.github.io/vicinote-authentication-with-rails-8/"><![CDATA[<p>L’<a href="/it/vicinote-project-setup-and-domain/">episodio 1</a> si era chiuso con una decisione di progettazione e niente su cui accedere: un solo modello <code class="language-plaintext highlighter-rouge">User</code>, nessuna colonna ruolo, fornitore e cliente che emergono entrambi da associazioni per cui non avevamo ancora scritto codice. Questo episodio scrive quel modello — e, insieme, tutto il sistema di autenticazione attorno a esso.</p>

<p>Il codice è taggato <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial/tree/episode-2"><code class="language-plaintext highlighter-rouge">episode-2</code></a> nel repo <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial">vicinote-tutorial</a>.</p>

<h2 id="non-devise">Non Devise</h2>

<p>Ogni versione passata di “aggiungi l’autenticazione a un’app Rails” iniziava con <code class="language-plaintext highlighter-rouge">gem "devise"</code>. Rails 8 ha cambiato le cose: adesso c’è un generator integrato, <code class="language-plaintext highlighter-rouge">bin/rails generate authentication</code>, che scrive codice Rails semplice e ordinario — modelli, controller, un concern — direttamente nella tua app. Nessuna gemma, nessun engine, nessun codice generato che vive da qualche parte in una gemma che non puoi leggere facilmente.</p>

<p>Questa distinzione conta più di quanto sembri. Con Devise, capire “come funziona davvero il login” significa leggere il codice sorgente della gemma. Con il generator di Rails 8, il codice che scrive <strong>è</strong> il tuo codice, sta dentro <code class="language-plaintext highlighter-rouge">app/</code> come tutto il resto, pronto per essere letto, modificato e — come vedremo a metà di questo episodio — esteso, perché il generator deliberatamente non fa tutto.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate authentication
</code></pre></div></div>

<p>Ecco l’elenco completo di cosa ha prodotto quel singolo comando:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>create  app/views/passwords/new.html.erb
create  app/views/passwords/edit.html.erb
create  app/views/sessions/new.html.erb
create  app/models/session.rb
create  app/models/user.rb
create  app/models/current.rb
create  app/controllers/sessions_controller.rb
create  app/controllers/concerns/authentication.rb
create  app/controllers/passwords_controller.rb
create  app/mailers/passwords_mailer.rb
create  app/views/passwords_mailer/reset.html.erb
create  app/views/passwords_mailer/reset.text.erb
insert  app/controllers/application_controller.rb
 route  resources :passwords, param: :token
 route  resource :session
  gsub  Gemfile
  create  db/migrate/..._create_users.rb
  create  db/migrate/..._create_sessions.rb
</code></pre></div></div>

<p>Vale la pena leggerli tutti prima di scrivere qualcosa di nostro, perché — a differenza di una gemma — guarderemo direttamente questo codice per il resto della serie.</p>

<h2 id="il-modello-user">Il modello User</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/user.rb</span>
<span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_secure_password</span>
  <span class="n">has_many</span> <span class="ss">:sessions</span><span class="p">,</span> <span class="ss">dependent: :destroy</span>

  <span class="n">normalizes</span> <span class="ss">:email_address</span><span class="p">,</span> <span class="ss">with: </span><span class="o">-&gt;</span><span class="p">(</span><span class="n">e</span><span class="p">)</span> <span class="p">{</span> <span class="n">e</span><span class="p">.</span><span class="nf">strip</span><span class="p">.</span><span class="nf">downcase</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">has_secure_password</code> è la riga che fa il lavoro pesante, e non è una scatola nera in stile Devise — è Rails puro, da <code class="language-plaintext highlighter-rouge">ActiveModel::SecurePassword</code>. Si aspetta una colonna <code class="language-plaintext highlighter-rouge">password_digest</code>, aggiunge attributi virtuali <code class="language-plaintext highlighter-rouge">password=</code>/<code class="language-plaintext highlighter-rouge">password_confirmation=</code> che vengono hashati con bcrypt all’assegnazione, e aggiunge un metodo d’istanza <code class="language-plaintext highlighter-rouge">authenticate</code>. Ci torneremo più avanti, perché fa più di questo — il meccanismo di reset password più avanti in questo post viene esattamente dalla stessa riga.</p>

<p>(Vale la pena essere precisi su una cosa: <code class="language-plaintext highlighter-rouge">has_secure_password</code> in sé è codice di Rails, parte della gemma <code class="language-plaintext highlighter-rouge">activemodel</code> — non fa parte di <code class="language-plaintext highlighter-rouge">bcrypt</code>. La gemma <code class="language-plaintext highlighter-rouge">bcrypt</code> nel Gemfile fornisce solo la classe <code class="language-plaintext highlighter-rouge">BCrypt::Password</code> che Rails usa internamente per hashare e confrontare davvero le password; la macro, le validazioni e la logica del token di reset vivono tutte dentro Rails stesso, leggibili come qualsiasi altro pezzo del framework.)</p>

<p><code class="language-plaintext highlighter-rouge">normalizes :email_address</code> è una funzionalità di Rails più piccola ma genuinamente utile: garantisce che <code class="language-plaintext highlighter-rouge">"Mario@Example.com "</code> e <code class="language-plaintext highlighter-rouge">"mario@example.com"</code> vengano trattati come lo stesso indirizzo ovunque — al salvataggio, e in ogni ricerca successiva — senza doverci ricordare di chiamare <code class="language-plaintext highlighter-rouge">.downcase.strip</code> a mano in ogni punto del codice.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_users.rb</span>
<span class="n">create_table</span> <span class="ss">:users</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:email_address</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:password_digest</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
<span class="n">add_index</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:email_address</span><span class="p">,</span> <span class="ss">unique: </span><span class="kp">true</span>
</code></pre></div></div>

<p>Nota cosa <strong>non</strong> c’è qui: nessun <code class="language-plaintext highlighter-rouge">name</code>, nessun ruolo, niente di specifico del marketplace. È volutamente il record autenticabile minimo indispensabile. Tutto quello della progettazione del dominio dell’episodio 1 — <code class="language-plaintext highlighter-rouge">has_many :services</code>, <code class="language-plaintext highlighter-rouge">has_many :bookings</code> — viene aggiunto quando costruiremo davvero <code class="language-plaintext highlighter-rouge">Service</code> e <code class="language-plaintext highlighter-rouge">Booking</code>, non adesso. Aggiungere quelle associazioni oggi funzionerebbe tecnicamente (Rails risolve i nomi delle classi delle associazioni in modo pigro), ma sarebbe codice che si riferisce a modelli che non esistono ancora, il che è peggio per chi legge questo repo dall’inizio alla fine.</p>

<h2 id="sessioni-e-current-come-si-traccia-chi-è-loggato">Sessioni e Current: come si traccia “chi è loggato”</h2>

<p>Questa è la parte che sembra più diversa da quello che ti aspetteresti venendo da Devise, e vale la pena rallentarci sopra.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/session.rb</span>
<span class="k">class</span> <span class="nc">Session</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:user</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_sessions.rb</span>
<span class="n">create_table</span> <span class="ss">:sessions</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:ip_address</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:user_agent</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Una <code class="language-plaintext highlighter-rouge">Session</code> è una <strong>riga di database</strong>, non solo un cookie cifrato. Ogni volta che qualcuno accede, viene creato un nuovo record <code class="language-plaintext highlighter-rouge">Session</code>, che salva quale utente, da quale IP, con quale browser. Il browser tiene solo l’<strong>id</strong> della sessione, firmato dentro un cookie così non può essere manomesso — lo stato vero della sessione vive lato server.</p>

<p>Il vantaggio pratico: puoi vedere ogni sessione attiva di un utente (<code class="language-plaintext highlighter-rouge">user.sessions</code>), e revocarne una singolarmente — disconnettere un dispositivo specifico — semplicemente cancellando quella riga. Una sessione basata solo su cookie non può farlo; puoi solo invalidarle <em>tutte</em> insieme (es. ruotando un secret).</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/current.rb</span>
<span class="k">class</span> <span class="nc">Current</span> <span class="o">&lt;</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">CurrentAttributes</span>
  <span class="n">attribute</span> <span class="ss">:session</span>
  <span class="n">delegate</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">to: :session</span><span class="p">,</span> <span class="ss">allow_nil: </span><span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ActiveSupport::CurrentAttributes</code> è un meccanismo di Rails per uno stato globale per-richiesta — più sicuro di una semplice variabile globale perché viene azzerato automaticamente tra una richiesta e l’altra (e tra un test e l’altro), quindi non c’è rischio che l’utente di una richiesta finisca dentro la successiva. <code class="language-plaintext highlighter-rouge">Current.session</code> contiene il record <code class="language-plaintext highlighter-rouge">Session</code> corrente per questa richiesta; <code class="language-plaintext highlighter-rouge">Current.user</code> è semplicemente <code class="language-plaintext highlighter-rouge">Current.session.user</code>, disponibile ovunque nell’app tramite <code class="language-plaintext highlighter-rouge">Current.user</code>, senza doverlo passare giù attraverso ogni chiamata di metodo.</p>

<h2 id="il-concern-authentication-sicuro-per-default">Il concern Authentication: sicuro per default</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/concerns/authentication.rb</span>
<span class="k">module</span> <span class="nn">Authentication</span>
  <span class="kp">extend</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Concern</span>

  <span class="n">included</span> <span class="k">do</span>
    <span class="n">before_action</span> <span class="ss">:require_authentication</span>
    <span class="n">helper_method</span> <span class="ss">:authenticated?</span>
  <span class="k">end</span>

  <span class="n">class_methods</span> <span class="k">do</span>
    <span class="k">def</span> <span class="nf">allow_unauthenticated_access</span><span class="p">(</span><span class="o">**</span><span class="n">options</span><span class="p">)</span>
      <span class="n">skip_before_action</span> <span class="ss">:require_authentication</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>
    <span class="k">def</span> <span class="nf">resume_session</span>
      <span class="no">Current</span><span class="p">.</span><span class="nf">session</span> <span class="o">||=</span> <span class="n">find_session_by_cookie</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">find_session_by_cookie</span>
      <span class="no">Session</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:session_id</span><span class="p">])</span> <span class="k">if</span> <span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:session_id</span><span class="p">]</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">start_new_session_for</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
      <span class="n">user</span><span class="p">.</span><span class="nf">sessions</span><span class="p">.</span><span class="nf">create!</span><span class="p">(</span><span class="ss">user_agent: </span><span class="n">request</span><span class="p">.</span><span class="nf">user_agent</span><span class="p">,</span> <span class="ss">ip_address: </span><span class="n">request</span><span class="p">.</span><span class="nf">remote_ip</span><span class="p">).</span><span class="nf">tap</span> <span class="k">do</span> <span class="o">|</span><span class="n">session</span><span class="o">|</span>
        <span class="no">Current</span><span class="p">.</span><span class="nf">session</span> <span class="o">=</span> <span class="n">session</span>
        <span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">.</span><span class="nf">permanent</span><span class="p">[</span><span class="ss">:session_id</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">value: </span><span class="n">session</span><span class="p">.</span><span class="nf">id</span><span class="p">,</span> <span class="ss">httponly: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">same_site: :lax</span> <span class="p">}</span>
      <span class="k">end</span>
    <span class="k">end</span>
    <span class="c1"># ...</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/application_controller.rb</span>
<span class="k">class</span> <span class="nc">ApplicationController</span> <span class="o">&lt;</span> <span class="no">ActionController</span><span class="o">::</span><span class="no">Base</span>
  <span class="kp">include</span> <span class="no">Authentication</span>
  <span class="c1"># ...</span>
<span class="k">end</span>
</code></pre></div></div>

<p>La riga che conta di più qui è <code class="language-plaintext highlighter-rouge">before_action :require_authentication</code> — inclusa direttamente in <code class="language-plaintext highlighter-rouge">ApplicationController</code>, il che significa che <strong>ogni controller dell’app richiede un utente loggato di default</strong>, a meno che non lo escluda esplicitamente con <code class="language-plaintext highlighter-rouge">allow_unauthenticated_access</code>. È l’opposto di come la maggior parte dei tutorial costruisce l’autenticazione (dove proteggi azioni specifiche), ed è un default deliberato e sensato per un’app il cui scopo intero sono gli account: è molto più sicuro doversi ricordare di rendere pubblica una pagina che doversi ricordare di proteggerla.</p>

<p>Vale la pena leggere con attenzione anche il cookie firmato in sé: <code class="language-plaintext highlighter-rouge">cookies.signed.permanent[...]</code> — firmato, quindi il valore non può essere falsificato (Rails lo verifica contro un secret prima di fidarsene); <code class="language-plaintext highlighter-rouge">permanent</code>, quindi impostato per scadere tra 20 anni invece che alla fine della sessione del browser; <code class="language-plaintext highlighter-rouge">httponly: true</code>, così il JavaScript lato client non può mai leggerlo (chiudendo un’intera categoria di furto di sessione via XSS); <code class="language-plaintext highlighter-rouge">same_site: :lax</code>, una protezione CSRF di base che impedisce l’invio del cookie su richieste cross-site tranne la navigazione di primo livello.</p>

<h3 id="una-trappola-la-nostra-landing-page-pubblica-si-è-appena-rotta">Una trappola: la nostra landing page pubblica si è appena rotta</h3>

<p>Poiché <code class="language-plaintext highlighter-rouge">require_authentication</code> è ora il default ovunque, la landing page dell’episodio 1 — pensata per essere la prima cosa che chiunque vede, loggato o no — reindirizzerebbe alla pagina di login nel momento in cui eseguiamo il generator. <code class="language-plaintext highlighter-rouge">PagesController</code> aveva bisogno di un’esclusione esplicita:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/pages_controller.rb</span>
<span class="k">class</span> <span class="nc">PagesController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">allow_unauthenticated_access</span>

  <span class="k">def</span> <span class="nf">home</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>È esattamente il compromesso che “sicuro per default” fa apposta: ci sbatterai contro la prima volta che aggiungi il generator a un’app con pagine pubbliche già esistenti, e la correzione è una riga sola, ma devi sapere che va cercata.</p>

<h2 id="accedere-authenticate_by-e-rate-limiting">Accedere: <code class="language-plaintext highlighter-rouge">authenticate_by</code> e rate limiting</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/sessions_controller.rb</span>
<span class="k">class</span> <span class="nc">SessionsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">allow_unauthenticated_access</span> <span class="ss">only: </span><span class="sx">%i[ new create ]</span>
  <span class="n">rate_limit</span> <span class="ss">to: </span><span class="mi">10</span><span class="p">,</span> <span class="ss">within: </span><span class="mi">3</span><span class="p">.</span><span class="nf">minutes</span><span class="p">,</span> <span class="ss">only: :create</span><span class="p">,</span> <span class="ss">with: </span><span class="o">-&gt;</span> <span class="p">{</span> <span class="n">redirect_to</span> <span class="n">new_session_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Try again later."</span> <span class="p">}</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="k">if</span> <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">authenticate_by</span><span class="p">(</span><span class="n">params</span><span class="p">.</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:email_address</span><span class="p">,</span> <span class="ss">:password</span><span class="p">))</span>
      <span class="n">start_new_session_for</span> <span class="n">user</span>
      <span class="n">redirect_to</span> <span class="n">after_authentication_url</span>
    <span class="k">else</span>
      <span class="n">redirect_to</span> <span class="n">new_session_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Try another email address or password."</span>
    <span class="k">end</span>
  <span class="k">end</span>
  <span class="c1"># ...</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Due dettagli qui vale davvero la pena capirli, non solo copiarli.</p>

<p><strong><code class="language-plaintext highlighter-rouge">User.authenticate_by</code></strong> sembra solo un <code class="language-plaintext highlighter-rouge">find_by(email_address:) + authenticate(password)</code> in un’unica chiamata, ma è pensato specificamente per chiudere un attacco a tempo (timing attack). Dal commento nel codice sorgente di Rails:</p>

<blockquote>
  <p>Regardless of whether a record is found, <code class="language-plaintext highlighter-rouge">authenticate_by</code> will cryptographically digest the given password attributes. This behavior helps mitigate timing-based enumeration attacks, wherein an attacker can determine if a passworded record exists even without knowing the password.</p>
</blockquote>

<p>In concreto: se prima cercassi l’utente e facessi il confronto bcrypt solo quando ne trovi uno, una richiesta per un’email <strong>inesistente</strong> tornerebbe quasi istantaneamente (nessun lavoro bcrypt), mentre una richiesta per un’email <strong>reale</strong> con password sbagliata impiegherebbe i ~100ms che bcrypt richiede. Quella differenza di tempo basta a un attaccante per enumerare quali email hanno un account, semplicemente misurando i tempi di risposta. <code class="language-plaintext highlighter-rouge">authenticate_by</code> fa sempre il lavoro costoso di digest, trovato o no, così entrambi i casi impiegano lo stesso tempo.</p>

<p><strong><code class="language-plaintext highlighter-rouge">rate_limit</code></strong> è una funzionalità nativa di Rails 8 (<code class="language-plaintext highlighter-rouge">ActionController::RateLimiting</code>, nessuna gemma) — dieci tentativi ogni tre minuti su questa azione, sostenuta da <code class="language-plaintext highlighter-rouge">Rails.cache</code>, con un redirect e un messaggio invece di fallire silenziosamente. Un attacco a forza bruta con migliaia di combinazioni viene rallentato in modo significativo da questa sola riga.</p>

<h2 id="il-pezzo-mancante-la-registrazione">Il pezzo mancante: la registrazione</h2>

<p>Prova le rotte del generator e troverai <code class="language-plaintext highlighter-rouge">new_session_path</code> e <code class="language-plaintext highlighter-rouge">new_password_path</code> (reset), ma nulla per creare un <code class="language-plaintext highlighter-rouge">User</code> in primo luogo. Non è una dimenticanza — il generator non può indovinare i requisiti di registrazione della tua app (solo su invito? conferma email? OAuth?) quindi lascia la cosa interamente a te.</p>

<p>Per VicinoTe, chiunque deve potersi registrare — è un marketplace, non uno strumento amministrativo — quindi lo aggiungiamo noi, seguendo la stessa forma del codice generato:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/registrations_controller.rb</span>
<span class="k">class</span> <span class="nc">RegistrationsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">allow_unauthenticated_access</span>

  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">new</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">user_params</span><span class="p">)</span>

    <span class="k">if</span> <span class="vi">@user</span><span class="p">.</span><span class="nf">save</span>
      <span class="n">start_new_session_for</span> <span class="vi">@user</span>
      <span class="n">redirect_to</span> <span class="n">root_path</span><span class="p">,</span> <span class="ss">notice: </span><span class="s2">"Welcome to VicinoTe!"</span>
    <span class="k">else</span>
      <span class="n">render</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">status: :unprocessable_entity</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">user_params</span>
    <span class="n">params</span><span class="p">.</span><span class="nf">require</span><span class="p">(</span><span class="ss">:user</span><span class="p">).</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:email_address</span><span class="p">,</span> <span class="ss">:password</span><span class="p">,</span> <span class="ss">:password_confirmation</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resource</span> <span class="ss">:registration</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span>
</code></pre></div></div>

<p>Nulla qui è meccanismo nuovo — <code class="language-plaintext highlighter-rouge">start_new_session_for</code> è lo stesso metodo privato che usa <code class="language-plaintext highlighter-rouge">SessionsController</code>, chiamabile perché definito nel concern <code class="language-plaintext highlighter-rouge">Authentication</code> incluso in ogni controller. La registrazione, in questo design, è solo “crea uno <code class="language-plaintext highlighter-rouge">User</code>, poi fai esattamente quello che fa il login”.</p>

<h2 id="reset-password-senza-una-colonna-per-il-token">Reset password senza una colonna per il token</h2>

<p>Questo è il dettaglio che ho trovato più interessante da approfondire. <code class="language-plaintext highlighter-rouge">PasswordsController</code> chiama <code class="language-plaintext highlighter-rouge">User.find_by_password_reset_token!(token)</code> — un metodo che non esiste da nessuna parte in <code class="language-plaintext highlighter-rouge">user.rb</code>. Non è scritto a mano, e non c’è nemmeno una colonna <code class="language-plaintext highlighter-rouge">reset_password_token</code> nella tabella <code class="language-plaintext highlighter-rouge">users</code>. Da dove viene, allora?</p>

<p>Viene da <code class="language-plaintext highlighter-rouge">has_secure_password</code> stesso. Leggendo il codice sorgente di Rails (<code class="language-plaintext highlighter-rouge">ActiveModel::SecurePassword</code>):</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">has_secure_password</span><span class="p">(</span><span class="n">attribute</span> <span class="o">=</span> <span class="ss">:password</span><span class="p">,</span> <span class="ss">validations: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">reset_token: </span><span class="kp">true</span><span class="p">)</span>
  <span class="c1"># ...</span>
  <span class="k">if</span> <span class="n">reset_token</span> <span class="o">&amp;&amp;</span> <span class="nb">respond_to?</span><span class="p">(</span><span class="ss">:generates_token_for</span><span class="p">)</span>
    <span class="n">generates_token_for</span> <span class="ss">:"</span><span class="si">#{</span><span class="n">attribute</span><span class="si">}</span><span class="ss">_reset"</span><span class="p">,</span> <span class="ss">expires_in: </span><span class="mi">15</span><span class="p">.</span><span class="nf">minutes</span> <span class="k">do</span>
      <span class="n">public_send</span><span class="p">(</span><span class="ss">:"</span><span class="si">#{</span><span class="n">attribute</span><span class="si">}</span><span class="ss">_salt"</span><span class="p">)</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">last</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>
    <span class="k">end</span>
    <span class="c1"># definisce find_by_password_reset_token / find_by_password_reset_token!</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">generates_token_for</code> è il meccanismo generico di Rails per generare un token verificabile e con scadenza <strong>senza salvarlo da nessuna parte</strong> — è un payload firmato e codificato (tramite <code class="language-plaintext highlighter-rouge">ActiveSupport::MessageVerifier</code>), controllato e decodificato al ritorno. Qui, <code class="language-plaintext highlighter-rouge">has_secure_password</code> lo usa automaticamente, basandosi sugli ultimi 10 caratteri del salt bcrypt della password.</p>

<p>Quel dettaglio è la parte intelligente: il salt cambia ogni volta che la password cambia (bcrypt ne genera uno nuovo a ogni hash), il che significa che <strong>un link di reset viene automaticamente invalidato nell’istante in cui la password per cui è stato generato cambia davvero</strong> — senza nessun flag “usato” in più, nessun job di pulizia dei token, nulla da salvare. Il token scade comunque dopo 15 minuti, e <code class="language-plaintext highlighter-rouge">find_by_password_reset_token!</code> solleva <code class="language-plaintext highlighter-rouge">ActiveSupport::MessageVerifier::InvalidSignature</code> su un token scaduto o manomesso, che <code class="language-plaintext highlighter-rouge">PasswordsController</code> intercetta trasformandolo in un redirect con un messaggio comprensibile.</p>

<p>Non costruiamo il flusso completo oggi (serve una configurazione mailer vera, che avrà più senso quando VicinoTe sarà distribuito da qualche parte), ma vale la pena sapere che esiste, completamente collegato, nel momento stesso in cui <code class="language-plaintext highlighter-rouge">has_secure_password</code> è sul modello — una riga, e arriva insieme un meccanismo di reset password funzionante e sicuro.</p>

<h2 id="una-piccola-barra-di-navigazione-per-raggiungere-tutto-questo">Una piccola barra di navigazione, per raggiungere tutto questo</h2>

<p>Niente di quanto sopra ha un’interfaccia per arrivarci finché qualcosa non ci punta, quindi il layout riceve un header minimale:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%</span> <span class="k">if</span> <span class="n">authenticated?</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;span&gt;</span><span class="cp">&lt;%=</span> <span class="no">Current</span><span class="p">.</span><span class="nf">user</span><span class="p">.</span><span class="nf">email_address</span> <span class="cp">%&gt;</span><span class="nt">&lt;/span&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">button_to</span> <span class="s2">"Sign out"</span><span class="p">,</span> <span class="n">session_path</span><span class="p">,</span> <span class="ss">method: :delete</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">else</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Sign in"</span><span class="p">,</span> <span class="n">new_session_path</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Sign up"</span><span class="p">,</span> <span class="n">new_registration_path</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">authenticated?</code> è l’<code class="language-plaintext highlighter-rouge">helper_method</code> che il concern <code class="language-plaintext highlighter-rouge">Authentication</code> espone — è semplicemente <code class="language-plaintext highlighter-rouge">resume_session</code>, riutilizzato per rispondere a “qualcuno è loggato” senza reindirizzare.</p>

<h2 id="provarlo">Provarlo</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Registrati con qualsiasi email e password — atterri di nuovo sulla homepage, già loggato, con la tua email nella barra in alto. Esci, poi rientra; prova una password sbagliata e otterrai l’errore comprensibile invece di uno stack trace. Nulla di tutto questo tocca ancora <code class="language-plaintext highlighter-rouge">Service</code> o <code class="language-plaintext highlighter-rouge">Booking</code> — il punto di questo episodio è che gli account funzionano, dall’inizio alla fine, prima che ci si costruisca sopra qualsiasi cosa.</p>

<h2 id="cosa-viene-dopo">Cosa viene dopo</h2>

<p>L’episodio 3 costruisce <code class="language-plaintext highlighter-rouge">Service</code> e <code class="language-plaintext highlighter-rouge">Category</code>, e finalmente collega l’associazione <code class="language-plaintext highlighter-rouge">has_many :services</code> della progettazione dell’episodio 1 — il momento in cui un utente loggato può davvero elencare qualcosa che offre.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[L’episodio 1 si era chiuso con una decisione di progettazione e niente su cui accedere: un solo modello User, nessuna colonna ruolo, fornitore e cliente che emergono entrambi da associazioni per cui non avevamo ancora scritto codice. Questo episodio scrive quel modello — e, insieme, tutto il sistema di autenticazione attorno a esso.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/vicinote-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/vicinote-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="it"><title type="html">WhatsApp con Rails: setup del progetto e contatti</title><link href="https://antoninoscaffidi.github.io/it/whatsapp-rails-setup-and-contacts/" rel="alternate" type="text/html" title="WhatsApp con Rails: setup del progetto e contatti" /><published>2026-08-11T07:00:00+00:00</published><updated>2026-08-11T07:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/whatsapp-rails-setup-and-contacts.it</id><content type="html" xml:base="https://antoninoscaffidi.github.io/whatsapp-rails-setup-and-contacts/"><![CDATA[<p>Questo è il primo episodio di <strong>WhatsApp with Rails</strong>, una serie breve nata da un lavoro reale per un cliente: doveva integrare la messaggistica WhatsApp in un’app Rails, e prima di costruire il progetto completo volevo una piccola demo funzionante da mostrargli — e assicurarmi di aver capito davvero l’integrazione, non solo di aver seguito un tutorial.</p>

<p>Lo scope qui è volutamente ristretto. Niente CRM, niente campagne, niente template messaggi, niente statistiche — quello è il progetto vero, e non è di cosa parla questa serie. Questa serie parla di una cosa sola: far partire un vero messaggio WhatsApp da un’app Rails, capendo cosa succede davvero quando lo fai. Lo costruiremo in due modi — passando da Twilio (questo episodio e il prossimo), e poi, come episodio a parte, chiamando direttamente le API Meta WhatsApp Cloud, senza Twilio in mezzo, così i due approcci si possono confrontare.</p>

<p>Il codice è su GitHub, taggato <a href="https://github.com/AntoninoScaffidi/whatsapp-with-rails/tree/episode-1"><code class="language-plaintext highlighter-rouge">episode-1</code></a>, nel repo <a href="https://github.com/AntoninoScaffidi/whatsapp-with-rails">whatsapp-with-rails</a>.</p>

<h2 id="cosa-costruiamo-e-perché-prima-twilio">Cosa costruiamo, e perché prima Twilio</h2>

<p>Prima di scrivere codice, vale la pena capire cos’è davvero Twilio qui, perché “API WhatsApp” è una frase un po’ ambigua — ci sono due modi per arrivarci.</p>

<p><strong>Meta possiede WhatsApp</strong>, e Meta offre davvero un’API diretta (la WhatsApp Business Cloud API) per inviare e ricevere messaggi in modo programmatico. Puoi integrarti direttamente con quella. Ma l’API di Meta richiede un account Meta Business, una review dell’app, la registrazione del numero attraverso il sistema di Meta stesso, e la sua autenticazione e configurazione dei webhook sono specifiche di Meta.</p>

<p><strong>Twilio si appoggia sopra a tutto questo.</strong> È una piattaforma di comunicazione che ha già fatto l’integrazione con Meta, avvolta in un’API REST più semplice e ben documentata (con tanto di gemma Ruby) che ha lo stesso aspetto sia che tu stia mandando WhatsApp, SMS o una chiamata vocale. Ti serve comunque una registrazione del mittente approvata per WhatsApp in entrambi i casi, ma la modalità sandbox di Twilio ti fa mandare messaggi di test in pochi minuti, senza aspettare il processo di approvazione di Meta.</p>

<p>Per questo l’episodio 1 e 2 usano Twilio: è la strada più veloce per far arrivare davvero un messaggio su un telefono, ed è una scelta legittima anche in produzione, non solo una scorciatoia — parecchi prodotti reali fanno passare la loro messaggistica WhatsApp da Twilio in modo permanente. L’episodio 3 poi fa lo stesso lavoro passando direttamente dall’API di Meta, così il compromesso (semplicità e velocità contro un servizio in meno nel mezzo) si vede nel codice vero, non solo in astratto.</p>

<h2 id="creare-lapp">Creare l’app</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails new whatsapp-with-rails <span class="nt">-d</span> postgresql <span class="nt">--css</span> tailwind
</code></pre></div></div>

<p>Stessa logica delle altre serie su questo blog: PostgreSQL perché è un default ragionevole per qualcosa che potrebbe crescere, Tailwind per mantenere le view leggibili senza un foglio di stile separato. Qui non c’è ancora nulla di specifico di Twilio — questo comando è identico a come inizieresti qualsiasi piccola app Rails.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:create
</code></pre></div></div>

<h2 id="il-modello-contact">Il modello Contact</h2>

<p>Il progetto vero ha un CRM completo — segmenti, tag, tracciamento del consenso GDPR, import/export. Qui ci servono esattamente due campi: chi, e quale numero contattare.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate model Contact name:string whatsapp_number:string
</code></pre></div></div>

<p>Due cose da fare prima di migrare: rendere entrambi i campi obbligatori, e validare per bene il formato del numero — perché la messaggistica WhatsApp <strong>richiede</strong> un formato specifico, ed è molto meglio intercettare un numero malformato in una validazione del form che in una chiamata API fallita più tardi.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_contacts.rb</span>
<span class="n">create_table</span> <span class="ss">:contacts</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:whatsapp_number</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>

  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/contact.rb</span>
<span class="k">class</span> <span class="nc">Contact</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">validates</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>
  <span class="n">validates</span> <span class="ss">:whatsapp_number</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">format: </span><span class="p">{</span>
    <span class="ss">with: </span><span class="sr">/\A\+[1-9]\d{6,14}\z/</span><span class="p">,</span>
    <span class="ss">message: </span><span class="s2">"must be in E.164 format, e.g. +391234567890"</span>
  <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="perché-proprio-e164">Perché proprio E.164</h3>

<p><strong>E.164</strong> è lo standard internazionale dell’ITU per la formattazione dei numeri di telefono — il formato che garantisce che un numero sia univoco in tutto il mondo. La forma è <code class="language-plaintext highlighter-rouge">+</code> seguito dal prefisso internazionale del paese, seguito dal numero dell’abbonato, senza spazi, trattini, parentesi, senza lo zero iniziale sulla parte locale dove il piano di numerazione del paese normalmente ne prevederebbe uno.</p>

<p>Un esempio concreto: un cellulare italiano che normalmente scriveresti come <code class="language-plaintext highlighter-rouge">333 1234567</code> diventa in E.164 <code class="language-plaintext highlighter-rouge">+393331234567</code> — prefisso <code class="language-plaintext highlighter-rouge">39</code>, poi il numero così com’è (i cellulari italiani non portano uno zero di trunk iniziale già di loro).</p>

<p>Perché conta qui in particolare: l’API WhatsApp di Twilio — e il protocollo WhatsApp stesso alla base — richiede E.164. Senza un unico formato univoco, <code class="language-plaintext highlighter-rouge">333-1234567</code> non ha senso fuori contesto: manca il prefisso del paese? Va tolto uno zero iniziale? E.164 elimina ognuno di questi dubbi.</p>

<p>La regex rispecchia direttamente lo standard: <code class="language-plaintext highlighter-rouge">+</code>, poi una cifra da <code class="language-plaintext highlighter-rouge">1</code> a <code class="language-plaintext highlighter-rouge">9</code> (i prefissi paese non iniziano mai con <code class="language-plaintext highlighter-rouge">0</code>), poi altre 6-14 cifre — che corrisponde esattamente al limite reale di E.164 di 15 cifre totali dopo il <code class="language-plaintext highlighter-rouge">+</code>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:migrate
</code></pre></div></div>

<h2 id="rotte-controller-elenco-contatti">Rotte, controller, elenco contatti</h2>

<p>Solo il minimo REST per elencare i contatti e aggiungerne uno — niente modifica, niente cancellazione, non servono per questa demo:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resources</span> <span class="ss">:contacts</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:index</span><span class="p">,</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span>
<span class="n">root</span> <span class="s2">"contacts#index"</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/contacts_controller.rb</span>
<span class="k">class</span> <span class="nc">ContactsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">index</span>
    <span class="vi">@contacts</span> <span class="o">=</span> <span class="no">Contact</span><span class="p">.</span><span class="nf">order</span><span class="p">(</span><span class="ss">:name</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@contact</span> <span class="o">=</span> <span class="no">Contact</span><span class="p">.</span><span class="nf">new</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@contact</span> <span class="o">=</span> <span class="no">Contact</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">contact_params</span><span class="p">)</span>

    <span class="k">if</span> <span class="vi">@contact</span><span class="p">.</span><span class="nf">save</span>
      <span class="n">redirect_to</span> <span class="n">contacts_path</span><span class="p">,</span> <span class="ss">notice: </span><span class="s2">"Contact added."</span>
    <span class="k">else</span>
      <span class="n">render</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">status: :unprocessable_entity</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">contact_params</span>
    <span class="n">params</span><span class="p">.</span><span class="nf">require</span><span class="p">(</span><span class="ss">:contact</span><span class="p">).</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:name</span><span class="p">,</span> <span class="ss">:whatsapp_number</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Due cose da segnalare, entrambe lezioni riprese dalle altre serie di questo blog: <code class="language-plaintext highlighter-rouge">create</code> <strong>reindirizza</strong> dopo un salvataggio riuscito (il pattern Post/Redirect/Get — vedi l’<a href="/it/persisting-conversations-with-activerecord/">episodio 3 di AI with Ruby</a> per capire perché conta in particolare per Turbo Drive), e un salvataggio fallito <strong>renderizza con <code class="language-plaintext highlighter-rouge">status: :unprocessable_entity</code></strong> invece del <code class="language-plaintext highlighter-rouge">200 OK</code> di default — lo status HTTP corretto per “la richiesta è stata capita ma i dati non erano validi”, e qualcosa che Turbo stesso controlla per decidere se trattare una risposta di form come un errore.</p>

<p>Le view sono un elenco semplice e un form semplice — niente di nuovo qui, quindi non le ripeto per intero; sono nel repo se vuoi vedere il markup Tailwind.</p>

<h2 id="provarlo">Provarlo</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Apri <code class="language-plaintext highlighter-rouge">http://127.0.0.1:3000</code>: un elenco contatti vuoto, un pulsante “Add contact”, un form. Prova a inviare un numero senza il <code class="language-plaintext highlighter-rouge">+</code> — la validazione lo intercetta, con il messaggio d’errore esatto che spiega cosa ci si aspetta.</p>

<p>Qui nulla parla ancora con Twilio. È voluto — l’episodio 2 collega la gemma <code class="language-plaintext highlighter-rouge">twilio-ruby</code> e manda davvero un messaggio a uno di questi contatti.</p>

<h2 id="cosa-viene-dopo">Cosa viene dopo</h2>

<p>L’episodio 2 aggiunge la gemma <code class="language-plaintext highlighter-rouge">twilio-ruby</code>, un form per comporre un messaggio, e la chiamata API che lo invia — più cos’è davvero una sandbox WhatsApp di Twilio e perché ti serve prima che Meta approvi il tuo mittente.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Questo è il primo episodio di WhatsApp with Rails, una serie breve nata da un lavoro reale per un cliente: doveva integrare la messaggistica WhatsApp in un’app Rails, e prima di costruire il progetto completo volevo una piccola demo funzionante da mostrargli — e assicurarmi di aver capito davvero l’integrazione, non solo di aver seguito un tutorial.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/whatsapp-with-rails-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/whatsapp-with-rails-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="it"><title type="html">Ruby Deep Dive: variabili e tipi base</title><link href="https://antoninoscaffidi.github.io/it/ruby-variables-and-basic-types/" rel="alternate" type="text/html" title="Ruby Deep Dive: variabili e tipi base" /><published>2026-08-09T07:00:00+00:00</published><updated>2026-08-09T07:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/ruby-variables-and-basic-types.it</id><content type="html" xml:base="https://antoninoscaffidi.github.io/ruby-variables-and-basic-types/"><![CDATA[<p>Questo è il primo episodio di <strong>Ruby Deep Dive</strong>, una serie un po’ diversa dalle altre due su questo blog. VicinoTe e AI with Ruby parlano entrambe di <em>costruire cose</em>. Questa parla di <em>capire il linguaggio in sé</em> — Ruby, da solo, niente Rails, niente framework. Non ha una cadenza fissa: viene scritta quando c’è tempo per affrontare un concetto per bene.</p>

<p>Ogni episodio ha esercizi in un repo di accompagnamento: <a href="https://github.com/AntoninoScaffidi/ruby-deep-dive">ruby-deep-dive</a>, taggato <a href="https://github.com/AntoninoScaffidi/ruby-deep-dive/tree/episode-1"><code class="language-plaintext highlighter-rouge">episode-1</code></a>. Ognuno è uno stub di metodo da completare, verificato da un test. Leggi l’episodio, poi vai a far passare i test da solo — gli esercizi restano volutamente irrisolti nel repo, perché farli è il punto.</p>

<p>Iniziamo dal vero inizio: cos’è una variabile, e i tipi base di Ruby.</p>

<h2 id="cosè-davvero-una-variabile">Cos’è davvero una variabile</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">language</span> <span class="o">=</span> <span class="s2">"Ruby"</span>
</code></pre></div></div>

<p>Questa riga fa due cose: crea un oggetto <code class="language-plaintext highlighter-rouge">String</code> che contiene il testo <code class="language-plaintext highlighter-rouge">"Ruby"</code>, e fa sì che il nome <code class="language-plaintext highlighter-rouge">language</code> punti a esso. Vale la pena essere precisi su questo, perché “variabile” può essere una parola fuorviante se vieni da un linguaggio dove le variabili sono scatole che contengono valori direttamente. In Ruby, una variabile è un’<strong>etichetta</strong>, e ciò che etichetta è un <strong>oggetto</strong> che vive da qualche parte in memoria. Due variabili possono puntare esattamente allo stesso oggetto:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">a</span> <span class="o">=</span> <span class="s2">"Ruby"</span>
<span class="n">b</span> <span class="o">=</span> <span class="n">a</span>
<span class="n">b</span><span class="p">.</span><span class="nf">upcase!</span>
<span class="n">a</span> <span class="c1">#=&gt; "RUBY"</span>
</code></pre></div></div>

<p>Cambiare <code class="language-plaintext highlighter-rouge">b</code> ha cambiato anche <code class="language-plaintext highlighter-rouge">a</code>, perché non erano mai state due stringhe separate — erano due etichette sullo stesso oggetto. <code class="language-plaintext highlighter-rouge">upcase!</code> (con il <code class="language-plaintext highlighter-rouge">!</code>) muta la stringa sul posto, invece di restituirne una nuova. Questa distinzione — se una variabile sia un’etichetta o una scatola — è la radice di molta confusione più avanti con gli argomenti dei metodi e la mutazione, quindi vale la pena averla chiara fin dall’episodio 1.</p>

<p>Ruby non richiede di dichiarare il tipo di una variabile. <code class="language-plaintext highlighter-rouge">language</code> non è “una variabile stringa” — è solo un nome che al momento punta a una <code class="language-plaintext highlighter-rouge">String</code>. Assegnale qualcos’altro, e punterà a quello:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">language</span> <span class="o">=</span> <span class="s2">"Ruby"</span>
<span class="n">language</span> <span class="o">=</span> <span class="mi">42</span>
</code></pre></div></div>

<p>Entrambe le righe sono perfettamente legali, una dopo l’altra. La variabile non ha cambiato tipo — ha solo iniziato a puntare altrove.</p>

<h2 id="come-si-nominano-le-variabili-locali">Come si nominano le variabili locali</h2>

<p>Un nome di variabile locale inizia con una lettera minuscola o un underscore, e per convenzione usa <code class="language-plaintext highlighter-rouge">snake_case</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">favorite_language</span> <span class="o">=</span> <span class="s2">"Ruby"</span>
<span class="n">_unused</span> <span class="o">=</span> <span class="s2">"inizia con un underscore, spesso usato per segnalare 'so che non la sto usando'"</span>
</code></pre></div></div>

<p>Ruby è più rigido di molti linguaggi su questa convenzione: i nomi in <code class="language-plaintext highlighter-rouge">CamelCase</code> sono riservati a costanti e nomi di classi/moduli, quindi <code class="language-plaintext highlighter-rouge">FavoriteLanguage = "Ruby"</code> non crea una variabile locale — crea una <em>costante</em>, una cosa di tipo completamente diverso, e Ruby ti avviserà se provi a riassegnarla.</p>

<h2 id="i-tipi-base">I tipi base</h2>

<h3 id="string">String</h3>

<p>Testo, tra virgolette doppie o singole. La differenza conta: le stringhe tra virgolette doppie supportano l’<strong>interpolazione</strong> e le sequenze di escape, quelle a virgolette singole no.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">name</span> <span class="o">=</span> <span class="s2">"Antonino"</span>
<span class="s2">"Hello, </span><span class="si">#{</span><span class="nb">name</span><span class="si">}</span><span class="s2">!"</span>   <span class="c1">#=&gt; "Hello, Antonino!"</span>
<span class="s1">'Hello, #{name}!'</span>   <span class="c1">#=&gt; "Hello, #{name}!" (letteralmente, nessuna interpolazione)</span>
</code></pre></div></div>

<p>L’interpolazione — <code class="language-plaintext highlighter-rouge">#{...}</code> dentro una stringa a virgolette doppie — valuta qualsiasi espressione Ruby ci sia dentro le graffe e ne inserisce la forma testuale. È il modo idiomatico di costruire stringhe in Ruby; usala invece della concatenazione con <code class="language-plaintext highlighter-rouge">+</code>.</p>

<h3 id="symbol">Symbol</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">:ruby</span>
</code></pre></div></div>

<p>Un <code class="language-plaintext highlighter-rouge">Symbol</code> assomiglia a una stringa con i due punti davanti, ed è tentante pensarlo come “una stringa che non può cambiare”. È vicino al vero, ma il modo più utile di pensarci è: un <code class="language-plaintext highlighter-rouge">Symbol</code> è un <strong>nome</strong>, usato come identificatore, non come dato da mostrare o manipolare. Lo stesso symbol scritto due volte è sempre esattamente lo stesso oggetto in memoria:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">:ruby</span><span class="p">.</span><span class="nf">object_id</span> <span class="o">==</span> <span class="ss">:ruby</span><span class="p">.</span><span class="nf">object_id</span>  <span class="c1">#=&gt; true</span>
<span class="s2">"ruby"</span><span class="p">.</span><span class="nf">object_id</span> <span class="o">==</span> <span class="s2">"ruby"</span><span class="p">.</span><span class="nf">object_id</span> <span class="c1">#=&gt; false</span>
</code></pre></div></div>

<p>Ecco perché i symbol compaiono continuamente come chiavi di hash e nomi di metodo nel codice Ruby — sono economici da confrontare (Ruby controlla solo se è lo stesso oggetto, non carattere per carattere) ed economici da salvare (nessuna copia duplicata). La regola pratica: se il valore è qualcosa che un umano leggerà a schermo, probabilmente è una <code class="language-plaintext highlighter-rouge">String</code>. Se è un’etichetta interna che il programma usa per riferirsi a qualcosa, probabilmente è un <code class="language-plaintext highlighter-rouge">Symbol</code>.</p>

<h3 id="integer-e-float">Integer e Float</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="mi">42</span>        <span class="c1"># Integer</span>
<span class="mf">3.14</span>      <span class="c1"># Float</span>
</code></pre></div></div>

<p>Una cosa che sorprende chi viene da altri linguaggi: la divisione tra interi tronca, non arrotonda né solleva un errore.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="mi">7</span> <span class="o">/</span> <span class="mi">2</span>     <span class="c1">#=&gt; 3, non 3.5</span>
<span class="mi">7</span> <span class="o">/</span> <span class="mf">2.0</span>   <span class="c1">#=&gt; 3.5</span>
<span class="mf">7.0</span> <span class="o">/</span> <span class="mi">2</span>   <span class="c1">#=&gt; 3.5</span>
</code></pre></div></div>

<p>Se uno dei due operandi è un <code class="language-plaintext highlighter-rouge">Float</code>, il risultato è un <code class="language-plaintext highlighter-rouge">Float</code>. Se sono entrambi <code class="language-plaintext highlighter-rouge">Integer</code>, ottieni la divisione intera. È esattamente per questo che <code class="language-plaintext highlighter-rouge">add_as_float</code> negli esercizi di questo episodio chiede di convertire prima di sommare — <code class="language-plaintext highlighter-rouge">2 + 3</code> fa <code class="language-plaintext highlighter-rouge">5</code>, un <code class="language-plaintext highlighter-rouge">Integer</code>, in qualunque modo tu scriva il metodo.</p>

<h3 id="nil-true-e-false">nil, true e false</h3>

<p><code class="language-plaintext highlighter-rouge">nil</code> rappresenta l’assenza di un valore — non zero, non una stringa vuota, davvero <em>niente</em>. <code class="language-plaintext highlighter-rouge">true</code> e <code class="language-plaintext highlighter-rouge">false</code> sono gli unici due valori di un tipo separato ciascuno, rispettivamente <code class="language-plaintext highlighter-rouge">TrueClass</code> e <code class="language-plaintext highlighter-rouge">FalseClass</code> (sì, <code class="language-plaintext highlighter-rouge">true</code> e <code class="language-plaintext highlighter-rouge">false</code> sono ciascuno l’unica istanza della propria classe — vedremo perché non è strano quanto sembra quando arriveremo a “tutto è un oggetto” nell’episodio 5).</p>

<p>Ecco il dettaglio che inciampa quasi tutti quelli che arrivano da un altro linguaggio: <strong>in Ruby, solo <code class="language-plaintext highlighter-rouge">nil</code> e <code class="language-plaintext highlighter-rouge">false</code> sono falsy.</strong> Tutto il resto è truthy — incluso <code class="language-plaintext highlighter-rouge">0</code>, inclusa <code class="language-plaintext highlighter-rouge">""</code> (stringa vuota), inclusi array e hash vuoti.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="mi">0</span>
  <span class="nb">puts</span> <span class="s2">"questo viene eseguito"</span>   <span class="c1"># sì! 0 è truthy in Ruby</span>
<span class="k">end</span>

<span class="k">if</span> <span class="s2">""</span>
  <span class="nb">puts</span> <span class="s2">"anche questo"</span>  <span class="c1"># eseguito anch'esso</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Venendo da JavaScript, Python o PHP, dove <code class="language-plaintext highlighter-rouge">0</code> e <code class="language-plaintext highlighter-rouge">""</code> sono falsy, questa è la fonte di confusione più comune in assoluto del tipo “perché il mio <code class="language-plaintext highlighter-rouge">if</code> ha fatto questo”. Tienilo a mente e smetterà presto di sorprenderti.</p>

<h2 id="gli-esercizi">Gli esercizi</h2>

<p>Vai su <a href="https://github.com/AntoninoScaffidi/ruby-deep-dive/blob/main/01-variables-and-basic-types/exercises.rb"><code class="language-plaintext highlighter-rouge">01-variables-and-basic-types/exercises.rb</code></a> nel repo. Sette metodi, tutti vuoti, tutti riguardanti qualcosa di questo post: assegnazione, interpolazione, conversione da stringa a intero, symbol, controllo di <code class="language-plaintext highlighter-rouge">nil</code>, forzare un risultato <code class="language-plaintext highlighter-rouge">Float</code> e — volutamente — trovare “l’altro valore falsy” senza che sia nominato direttamente nell’esercizio.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/AntoninoScaffidi/ruby-deep-dive.git
<span class="nb">cd </span>ruby-deep-dive
bundle <span class="nb">install
</span>ruby 01-variables-and-basic-types/exercises_test.rb
</code></pre></div></div>

<p>Ogni test fallirà finché non completi il metodo corrispondente. È lo stato di partenza previsto — falli diventare verdi uno alla volta.</p>

<h2 id="cosa-viene-dopo">Cosa viene dopo</h2>

<p>L’episodio 2 copre il controllo di flusso: <code class="language-plaintext highlighter-rouge">if</code>/<code class="language-plaintext highlighter-rouge">unless</code>/<code class="language-plaintext highlighter-rouge">case</code>, i loop, e uno sguardo più da vicino a truthy/falsy ora che i tipi base sono a posto.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Questo è il primo episodio di Ruby Deep Dive, una serie un po’ diversa dalle altre due su questo blog. VicinoTe e AI with Ruby parlano entrambe di costruire cose. Questa parla di capire il linguaggio in sé — Ruby, da solo, niente Rails, niente framework. Non ha una cadenza fissa: viene scritta quando c’è tempo per affrontare un concetto per bene.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/ruby-deep-dive-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/ruby-deep-dive-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="it"><title type="html">Persistere le conversazioni RubyLLM con ActiveRecord</title><link href="https://antoninoscaffidi.github.io/it/persisting-conversations-with-activerecord/" rel="alternate" type="text/html" title="Persistere le conversazioni RubyLLM con ActiveRecord" /><published>2026-08-09T04:00:00+00:00</published><updated>2026-08-09T04:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/persisting-conversations-with-activerecord.it</id><content type="html" xml:base="https://antoninoscaffidi.github.io/persisting-conversations-with-activerecord/"><![CDATA[<p>Nell’<a href="/it/wiring-rubyllm-into-rails/">episodio 2</a> avevamo fatto parlare RubyLLM con Rails tramite un form semplice: scrivi un messaggio, ricevi una risposta, ricarichi la pagina e sparisce tutto. Ogni chiamata a <code class="language-plaintext highlighter-rouge">RubyLLM.chat</code> creava una sessione nuova, in memoria, che viveva solo per la durata di quella singola richiesta.</p>

<p>Questo episodio risolve il problema. Alla fine, le conversazioni sopravvivono a un refresh della pagina, vivono nel database, e il modello ricorda davvero cosa è stato detto prima nella stessa conversazione — perché gli stiamo inviando la cronologia reale, non solo l’ultimo messaggio.</p>

<p>Il codice è taggato <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo/tree/episode-3"><code class="language-plaintext highlighter-rouge">episode-3</code></a> nel repo <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo">ai-with-ruby-demo</a>.</p>

<h2 id="due-modi-per-persistere-una-chat">Due modi per persistere una chat</h2>

<p>Potresti scrivere a mano i modelli <code class="language-plaintext highlighter-rouge">Conversation</code> e <code class="language-plaintext highlighter-rouge">Message</code>: una coppia <code class="language-plaintext highlighter-rouge">has_many</code>/<code class="language-plaintext highlighter-rouge">belongs_to</code>, un controller che appende a un array di messaggi, un po’ di serializzazione JSON per salvare cosa ha detto il modello. Funzionerebbe, ma staresti ricostruendo qualcosa che RubyLLM offre già come integrazione Rails di prima classe, completa di generator, migrazioni, e il pattern <code class="language-plaintext highlighter-rouge">acts_as_chat</code> / <code class="language-plaintext highlighter-rouge">acts_as_message</code> che trovi nella documentazione ufficiale e negli esempi della gemma.</p>

<p>Useremo il generator. Non perché farlo a mano sia sbagliato, ma perché il codice generato <strong>è</strong> il modo idiomatico di usare questa gemma in Rails — leggerlo è istruttivo quanto scriverlo da soli, e finisci con un codice che corrisponde a quello che trovi nella documentazione di RubyLLM e nei progetti di altri.</p>

<h2 id="eseguire-il-generator">Eseguire il generator</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate ruby_llm:install chat:Conversation <span class="nt">--skip-active-storage</span>
</code></pre></div></div>

<p>Ci sono due cose non di default in questo comando, quindi rendiamole esplicite entrambe.</p>

<p><strong><code class="language-plaintext highlighter-rouge">chat:Conversation</code>.</strong> Lasciato al default, il generator chiama il modello che rappresenta una sessione di chat <code class="language-plaintext highlighter-rouge">Chat</code>. È un default ragionevole, ma “conversation” è la parola che useremo davvero per parlare di questa funzionalità, quindi diciamo al generator di chiamarlo <code class="language-plaintext highlighter-rouge">Conversation</code>. La riga d’uso del generator stesso spiega la sintassi: <code class="language-plaintext highlighter-rouge">bin/rails g ruby_llm:install [chat:ChatName] [message:MessageName] ...</code> — puoi rinominare allo stesso modo qualsiasi modello che crea.</p>

<p><strong><code class="language-plaintext highlighter-rouge">--skip-active-storage</code>.</strong> Di default il generator esegue anche <code class="language-plaintext highlighter-rouge">active_storage:install</code> e aggiunge <code class="language-plaintext highlighter-rouge">has_many_attached :attachments</code> al modello message, così una chat può avere file allegati. Non stiamo costruendo allegati file in questo episodio, quindi lo saltiamo. Nulla impedisce di aggiungerlo dopo — il flag lo salta solo <strong>ora</strong>.</p>

<h2 id="cosa-crea-davvero-il-generator">Cosa crea davvero il generator</h2>

<p>Vale la pena passarlo in rassegna file per file, perché crea più di un <code class="language-plaintext highlighter-rouge">Conversation</code> e un <code class="language-plaintext highlighter-rouge">Message</code> — ed è meglio sapere perché piuttosto che ritrovarsi file inspiegati nell’app.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>create  db/migrate/..._create_conversations.rb
create  db/migrate/..._create_messages.rb
create  db/migrate/..._create_tool_calls.rb
create  db/migrate/..._create_models.rb
create  db/migrate/..._add_references_to_conversations_tool_calls_and_messages.rb
create  app/models/conversation.rb
create  app/models/message.rb
create  app/models/tool_call.rb
create  app/models/model.rb
force   config/initializers/ruby_llm.rb
create  app/agents/.gitkeep
create  app/tools/.gitkeep
create  app/schemas/.gitkeep
create  app/prompts/.gitkeep
</code></pre></div></div>

<p><strong>Tabella <code class="language-plaintext highlighter-rouge">conversations</code>.</strong> Solo un guscio vuoto più i timestamp — una conversazione da sola non deve salvare nulla, serve solo a raggruppare i messaggi. Un riferimento <code class="language-plaintext highlighter-rouge">model_id</code> viene aggiunto più avanti dalla quinta migrazione, per registrare quale modello LLM sta usando quella conversazione.</p>

<p><strong>Tabella <code class="language-plaintext highlighter-rouge">messages</code>.</strong> È qui che sta la sostanza: <code class="language-plaintext highlighter-rouge">role</code> (<code class="language-plaintext highlighter-rouge">"user"</code>, <code class="language-plaintext highlighter-rouge">"assistant"</code>, o <code class="language-plaintext highlighter-rouge">"system"</code>), <code class="language-plaintext highlighter-rouge">content</code>, e <code class="language-plaintext highlighter-rouge">content_raw</code> (una colonna JSON che contiene la struttura grezza completa, necessaria per messaggi che non sono testo semplice — come le chiamate a tool). Ci sono anche colonne per il “extended thinking” (<code class="language-plaintext highlighter-rouge">thinking_text</code>, <code class="language-plaintext highlighter-rouge">thinking_signature</code>, <code class="language-plaintext highlighter-rouge">thinking_tokens</code> — per i modelli che espongono il proprio processo di ragionamento) e per il conteggio dei token (<code class="language-plaintext highlighter-rouge">input_tokens</code>, <code class="language-plaintext highlighter-rouge">output_tokens</code>, <code class="language-plaintext highlighter-rouge">cached_tokens</code>, <code class="language-plaintext highlighter-rouge">cache_creation_tokens</code>). Non toccheremo le colonne di thinking o dei token in questo episodio, ma vale la pena sapere che ci sono: questa stessa tabella è costruita per supportare funzionalità che arriveranno diversi episodi più avanti.</p>

<p><strong>Tabella <code class="language-plaintext highlighter-rouge">tool_calls</code>.</strong> Non ancora usata — è per un episodio successivo dedicato al tool calling, quando il modello potrà richiamare il codice della nostra applicazione.</p>

<p><strong>Tabella <code class="language-plaintext highlighter-rouge">models</code>.</strong> Una cache locale dei metadati dei modelli — prezzi, dimensione della context window, quali capacità supporta un dato modello. Viene popolata da un rake task separato (<code class="language-plaintext highlighter-rouge">bin/rails ruby_llm:load_models</code>) che non eseguiamo in questo episodio, dato che nulla dipende ancora da essa. Il riferimento <code class="language-plaintext highlighter-rouge">model_id</code> aggiunto a <code class="language-plaintext highlighter-rouge">conversations</code> e <code class="language-plaintext highlighter-rouge">messages</code> è opzionale (<code class="language-plaintext highlighter-rouge">foreign_key: true</code>, senza <code class="language-plaintext highlighter-rouge">null: false</code>), quindi tutto funziona bene anche senza.</p>

<p><strong>I modelli generati:</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/conversation.rb</span>
<span class="k">class</span> <span class="nc">Conversation</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">acts_as_chat</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/message.rb</span>
<span class="k">class</span> <span class="nc">Message</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">acts_as_message</span> <span class="ss">chat: :conversation</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">acts_as_chat</code> e <code class="language-plaintext highlighter-rouge">acts_as_message</code> sono class method che RubyLLM aggiunge ad <code class="language-plaintext highlighter-rouge">ActiveRecord::Base</code>. Impostano l’associazione <code class="language-plaintext highlighter-rouge">has_many</code>/<code class="language-plaintext highlighter-rouge">belongs_to</code> tra i due modelli e includono i metodi che fanno comportare un <code class="language-plaintext highlighter-rouge">Conversation</code> come una sessione di chat RubyLLM — soprattutto <code class="language-plaintext highlighter-rouge">.ask</code>. Poiché abbiamo rinominato il modello in <code class="language-plaintext highlighter-rouge">Conversation</code>, <code class="language-plaintext highlighter-rouge">acts_as_message</code> doveva sapere che l’associazione non si chiama più <code class="language-plaintext highlighter-rouge">chat</code>, da cui <code class="language-plaintext highlighter-rouge">chat: :conversation</code>. Il generator l’ha capito da solo a partire dalla mappatura <code class="language-plaintext highlighter-rouge">chat:Conversation</code> che gli abbiamo passato.</p>

<p><strong>Le directory di convenzione</strong> (<code class="language-plaintext highlighter-rouge">app/agents</code>, <code class="language-plaintext highlighter-rouge">app/tools</code>, <code class="language-plaintext highlighter-rouge">app/schemas</code>, <code class="language-plaintext highlighter-rouge">app/prompts</code>, ciascuna con solo un <code class="language-plaintext highlighter-rouge">.gitkeep</code> per ora) sono impalcature vuote per funzionalità più avanti in questa serie — tool che un agente può richiamare, schemi per output strutturati, prompt riutilizzabili. Per ora non ci va nulla.</p>

<h2 id="una-trappola-lapi-legacy-deprecata">Una trappola: l’API legacy deprecata</h2>

<p>Ecco una cosa che non è ovvia dal solo README, e in cui siamo incappati direttamente. RubyLLM include <strong>due</strong> implementazioni dell’integrazione ActiveRecord: quella attuale (quella appena descritta) e una legacy, mantenuta solo per retrocompatibilità. Quale delle due viene caricata è controllato da un flag di configurazione, <code class="language-plaintext highlighter-rouge">use_new_acts_as</code>.</p>

<p>Senza di esso, RubyLLM carica silenziosamente l’implementazione legacy e stampa questo all’avvio:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>!!! RubyLLM's legacy acts_as API is deprecated and will be removed in RubyLLM 2.0.0.
Please consult the migration guide at https://rubyllm.com/upgrading-to-1-7/
</code></pre></div></div>

<p>È esattamente il warning che avevamo visto nell’episodio 2, prima ancora di toccare la persistenza — viene stampato all’avvio non appena ActiveRecord si carica, indipendentemente dal fatto che tu stia già usando <code class="language-plaintext highlighter-rouge">acts_as_chat</code>. Il generator lo sa e imposta il flag correttamente da solo:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/ruby_llm.rb</span>
<span class="no">RubyLLM</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">openai_api_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"OPENAI_API_KEY"</span><span class="p">,</span> <span class="kp">nil</span><span class="p">)</span>

  <span class="c1"># Usa l'API acts_as attuale, basata sulle associazioni. Senza questo,</span>
  <span class="c1"># RubyLLM ricade silenziosamente su un'implementazione deprecata e avvisa.</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">use_new_acts_as</span> <span class="o">=</span> <span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Se mai aggiungerai l’integrazione ActiveRecord di RubyLLM a un progetto <strong>senza</strong> usare il generator, questa è la riga più importante da copiare a mano — è facile da perdere, e le due implementazioni non sono identiche, quindi codice scritto per una non funziona necessariamente con l’altra.</p>

<h2 id="eseguire-le-migrazioni">Eseguire le migrazioni</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:migrate
</code></pre></div></div>

<p>Questo crea tutte e quattro le tabelle descritte sopra, più la quinta migrazione che collega le chiavi esterne tra loro.</p>

<h2 id="provarlo-dalla-console-prima-di-toccare-il-controller">Provarlo dalla console, prima di toccare il controller</h2>

<p>Vale la pena farlo una volta, per vedere la persistenza funzionare senza nient’altro di mezzo:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails runner <span class="s1">'
conversation = Conversation.create!
response = conversation.ask "Reply with exactly one word: pong"
puts "RESPONSE: #{response.content}"
puts "MESSAGES IN DB: #{conversation.messages.count}"
puts "ROLES: #{conversation.messages.pluck(:role).join(%q{, })}"
'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RESPONSE: pong
MESSAGES IN DB: 2
ROLES: user, assistant
</code></pre></div></div>

<p>Una chiamata a <code class="language-plaintext highlighter-rouge">.ask</code>, due righe scritte: il messaggio dell’utente e la risposta del modello, entrambi salvati automaticamente. Nessun warning di deprecazione nemmeno — il flag sta facendo il suo lavoro.</p>

<h2 id="collegarlo-al-controller">Collegarlo al controller</h2>

<p>Il controller dell’episodio 2 creava una chat nuova e usa-e-getta a ogni richiesta. Ora dobbiamo ritrovare <strong>la stessa</strong> conversazione tra una richiesta e l’altra. Per una demo a singolo utente senza ancora account, la cosa più semplice che resti comunque corretta è tenere l’id della conversazione nella sessione di Rails — un piccolo cookie firmato legato al browser del visitatore.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/chats_controller.rb</span>
<span class="k">class</span> <span class="nc">ChatsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@conversation</span> <span class="o">=</span> <span class="n">current_conversation</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="n">current_conversation</span><span class="p">.</span><span class="nf">ask</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:message</span><span class="p">])</span>
    <span class="n">redirect_to</span> <span class="n">new_chat_path</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">current_conversation</span>
    <span class="no">Conversation</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">session</span><span class="p">[</span><span class="ss">:conversation_id</span><span class="p">])</span> <span class="o">||</span> <span class="n">create_conversation</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create_conversation</span>
    <span class="n">conversation</span> <span class="o">=</span> <span class="no">Conversation</span><span class="p">.</span><span class="nf">create!</span>
    <span class="n">session</span><span class="p">[</span><span class="ss">:conversation_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">conversation</span><span class="p">.</span><span class="nf">id</span>
    <span class="n">conversation</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">current_conversation</code> cerca la conversazione tramite l’id salvato nella sessione; se non ce n’è ancora una (prima visita, o sessione scaduta), ne crea una e ne ricorda l’id per la prossima volta. <code class="language-plaintext highlighter-rouge">create</code> non renderizza più nulla da solo — fa la domanda, poi reindirizza a <code class="language-plaintext highlighter-rouge">new</code>, che ora è responsabile di caricare e mostrare la conversazione, messaggi inclusi.</p>

<h2 id="mostrare-la-cronologia">Mostrare la cronologia</h2>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"max-w-xl mx-auto mt-16 px-4"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"text-2xl font-semibold mb-6"</span><span class="nt">&gt;</span>RubyLLM chat demo<span class="nt">&lt;/h1&gt;</span>

  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"space-y-4 mb-8"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%</span> <span class="vi">@conversation</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">message</span><span class="o">|</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%</span> <span class="n">is_user</span> <span class="o">=</span> <span class="n">message</span><span class="p">.</span><span class="nf">role</span> <span class="o">==</span> <span class="s2">"user"</span> <span class="cp">%&gt;</span>
      <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">is_user</span> <span class="p">?</span> <span class="s2">"text-right"</span> <span class="p">:</span> <span class="s2">"text-left"</span> <span class="cp">%&gt;</span><span class="s">"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-xs text-gray-500 mb-1"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">message</span><span class="p">.</span><span class="nf">role</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
        <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"inline-block rounded-md px-3 py-2 </span><span class="cp">&lt;%=</span> <span class="n">is_user</span> <span class="p">?</span> <span class="s2">"bg-indigo-600 text-white"</span> <span class="p">:</span> <span class="s2">"bg-gray-100"</span> <span class="cp">%&gt;</span><span class="s">"</span><span class="nt">&gt;</span>
          <span class="cp">&lt;%=</span> <span class="n">message</span><span class="p">.</span><span class="nf">content</span> <span class="cp">%&gt;</span>
        <span class="nt">&lt;/p&gt;</span>
      <span class="nt">&lt;/div&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>

  <span class="cp">&lt;%=</span> <span class="n">form_with</span> <span class="ss">url: </span><span class="n">chat_path</span><span class="p">,</span> <span class="ss">method: :post</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"flex flex-col gap-3"</span> <span class="k">do</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;textarea</span> <span class="na">name=</span><span class="s">"message"</span> <span class="na">rows=</span><span class="s">"3"</span> <span class="na">placeholder=</span><span class="s">"Ask something..."</span>
      <span class="na">class=</span><span class="s">"border border-gray-300 rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span><span class="nt">&gt;&lt;/textarea&gt;</span>
    <span class="nt">&lt;button</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">class=</span><span class="s">"self-start bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"</span><span class="nt">&gt;</span>
      Send
    <span class="nt">&lt;/button&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">@conversation.messages</code> arriva gratis da <code class="language-plaintext highlighter-rouge">acts_as_chat</code> — è l’associazione <code class="language-plaintext highlighter-rouge">has_many</code>, già ordinata dal più vecchio al più recente. Ci scorriamo sopra, e usiamo <code class="language-plaintext highlighter-rouge">message.role</code> (una semplice colonna stringa: <code class="language-plaintext highlighter-rouge">"user"</code> o <code class="language-plaintext highlighter-rouge">"assistant"</code>) per allineare e colorare diversamente ogni fumetto. Non c’è un metodo helper come <code class="language-plaintext highlighter-rouge">message.user?</code> — il ruolo è solo una stringa, quindi basta un confronto diretto.</p>

<h2 id="chiudere-il-filo-aperto-dellepisodio-2-turbo-drive-torna-a-funzionare">Chiudere il filo aperto dell’episodio 2: Turbo Drive torna a funzionare</h2>

<p>L’episodio 2 si era chiuso con <code class="language-plaintext highlighter-rouge">data: { turbo: false }</code> sul form, come soluzione temporanea: Turbo Drive intercettava l’invio e si aspettava o una risposta Turbo Stream o un redirect, e il nostro controller non faceva né l’uno né l’altro — renderizzava HTML semplice, che Turbo non sapeva come gestire, quindi la pagina falliva silenziosamente nell’aggiornarsi.</p>

<p>Guarda di nuovo l’azione <code class="language-plaintext highlighter-rouge">create</code> di questo episodio: non renderizza nulla, reindirizza. Questo è il pattern Post/Redirect/Get — dopo un invio di form che cambia qualcosa, reindirizzi a una pagina che mostra il risultato, invece di renderizzare un risultato direttamente dall’azione POST. È buona pratica di per sé (impedisce che un refresh della pagina reinvii il form), e per giunta è esattamente ciò che Turbo Drive si aspetta. Quindi la soluzione temporanea è sparita — il form di questo episodio non ha alcun <code class="language-plaintext highlighter-rouge">data: { turbo: false }</code>, e funziona.</p>

<p>Vale la pena soffermarcisi un attimo: la correzione dell’episodio 2 non era sbagliata, ma curava un sintomo. La vera correzione era adottare il pattern su cui Rails (e Turbo) sono costruiti fin dall’inizio.</p>

<h2 id="provarlo-nel-browser">Provarlo nel browser</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Apri <code class="language-plaintext highlighter-rouge">http://127.0.0.1:3000</code>, chiedi qualcosa, e la risposta appare sotto il form. Ricarica la pagina — la conversazione è ancora lì. Fai una domanda di follow-up che dipende dal primo messaggio (“cosa ti ho appena chiesto?”) e il modello risponde correttamente, perché la cronologia completa viene inviata a ogni richiesta, non solo l’ultima riga.</p>

<h2 id="cosa-viene-dopo">Cosa viene dopo</h2>

<p>L’episodio 4 copre le risposte in streaming: invece di aspettare la risposta completa prima di mostrare qualcosa, la faremo arrivare man mano che il modello la genera, usando Turbo Streams sul serio questa volta.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Nell’episodio 2 avevamo fatto parlare RubyLLM con Rails tramite un form semplice: scrivi un messaggio, ricevi una risposta, ricarichi la pagina e sparisce tutto. Ogni chiamata a RubyLLM.chat creava una sessione nuova, in memoria, che viveva solo per la durata di quella singola richiesta.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="it"><title type="html">VicinoTe: preparare un marketplace Rails e progettarne il dominio</title><link href="https://antoninoscaffidi.github.io/it/vicinote-project-setup-and-domain/" rel="alternate" type="text/html" title="VicinoTe: preparare un marketplace Rails e progettarne il dominio" /><published>2026-08-08T08:00:00+00:00</published><updated>2026-08-08T08:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/vicinote-project-setup-and-domain.it</id><content type="html" xml:base="https://antoninoscaffidi.github.io/vicinote-project-setup-and-domain/"><![CDATA[<p>Questo è il primo episodio di <strong>VicinoTe</strong>, una serie in cui costruiamo un’applicazione Rails completa partendo da una cartella vuota, fino ad arrivare — negli episodi finali — a un modulo di AI basato su RubyLLM.</p>

<p>Il nome viene da <em>vicino a te</em>. VicinoTe è un marketplace di servizi locali: trovi qualcuno nelle vicinanze che ti ripara un rubinetto, ti insegna la chitarra, porta a spasso il cane o ti ristruttura il bagno — oppure sei tu a offrire le tue competenze a chi ti sta intorno.</p>

<p>In questo episodio prepariamo il progetto e, cosa più importante, decidiamo cosa stiamo effettivamente costruendo. Il codice è su GitHub, taggato <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial/tree/episode-1"><code class="language-plaintext highlighter-rouge">episode-1</code></a> nel repo <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial">vicinote-tutorial</a>, che crescerà a ogni post.</p>

<h2 id="perché-un-marketplace">Perché un marketplace</h2>

<p>I tutorial costruiscono spesso un blog o una lista di cose da fare. Vanno bene per imparare la sintassi, ma sono troppo semplici per incontrare i problemi che rendono Rails interessante: lo stesso record che assume ruoli diversi a seconda del contesto, il denaro, le disponibilità, i permessi, una ricerca che deve funzionare davvero.</p>

<p>Un marketplace di servizi li tocca tutti, e lo fa in modo graduale: puoi avere qualcosa di funzionante dopo due episodi e avere ancora parecchio da costruire dopo dieci.</p>

<h2 id="creare-lapplicazione">Creare l’applicazione</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails new vicinote-tutorial <span class="nt">-d</span> postgresql <span class="nt">--css</span> tailwind
</code></pre></div></div>

<p>Questo comando racchiude tre decisioni, vediamole una per una.</p>

<p><strong><code class="language-plaintext highlighter-rouge">-d postgresql</code>.</strong> Rails 8 usa SQLite di default, che oggi è davvero una buona scelta. Opto comunque per PostgreSQL per una ragione precisa: gli episodi sull’AI più avanti in questa serie hanno bisogno della <strong>ricerca vettoriale</strong> per la ricerca semantica sui servizi. Il modo standard di farlo in Postgres è l’estensione <code class="language-plaintext highlighter-rouge">pgvector</code>, e potervi accedere senza dover migrare il database a metà serie vale il piccolo lavoro in più adesso.</p>

<p><strong><code class="language-plaintext highlighter-rouge">--css tailwind</code>.</strong> Preferenza personale, e mantiene le view leggibili senza dover gestire un foglio di stile separato accanto al tutorial. Nulla nella serie dipende specificamente da Tailwind: se preferisci il CSS semplice, il markup resterà comunque comprensibile.</p>

<p><strong>I default di Rails 8 che teniamo.</strong> Il generatore porta con sé anche Propshaft, Importmap, Turbo, Stimulus e il trio Solid (Solid Queue, Solid Cache, Solid Cable). Non ne configuriamo ancora nessuno, ma sono il motivo per cui in questo progetto non c’è Redis né un passaggio di build con Node.</p>

<p>Poi creiamo i database:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:create
</code></pre></div></div>

<p>Se il comando fallisce, PostgreSQL non è in esecuzione o non è raggiungibile: è l’unico pezzo di configurazione che devi sistemare sulla tua macchina prima di proseguire.</p>

<h2 id="progettare-il-dominio">Progettare il dominio</h2>

<p>Questa è la parte su cui vale la pena rallentare. Impostare bene il modello adesso evita parecchie migrazioni dolorose più avanti.</p>

<p>Ecco la forma a cui puntiamo:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>User            può offrire servizi E prenotarli
 ├─ Service     qualcosa che un utente offre (titolo, descrizione, prezzo)
 │   └─ Category
 └─ Booking     un utente prenota il servizio di un altro utente
     └─ Review  lasciata a prenotazione completata
</code></pre></div></div>

<h3 id="la-decisione-importante-un-solo-user-due-ruoli">La decisione importante: un solo User, due ruoli</h3>

<p>La domanda centrale in qualsiasi marketplace è come rappresentare i due lati della transazione. Ci sono tre risposte comuni.</p>

<p><strong>Opzione A — modelli separati.</strong> Un modello <code class="language-plaintext highlighter-rouge">Provider</code> e un modello <code class="language-plaintext highlighter-rouge">Customer</code>, ciascuno con la propria tabella.</p>

<p>È la prima idea che viene in mente a quasi tutti, ed è quasi sempre sbagliata. Duplichi subito tutto ciò che non è specifico del ruolo: email, password, nome, avatar, telefono, indirizzo. Poi ti serve un’autenticazione che funzioni per entrambi, il che significa o due flussi di login o un groviglio polimorfico. E nel momento in cui una persona che offre lezioni di chitarra vuole prenotare un idraulico, le servono due account con due password — cosa assurda, ed esattamente la situazione in cui un marketplace di quartiere si imbatte di continuo.</p>

<p><strong>Opzione B — una colonna <code class="language-plaintext highlighter-rouge">role</code> su User.</strong> Un’unica tabella <code class="language-plaintext highlighter-rouge">users</code> con <code class="language-plaintext highlighter-rouge">role: "provider"</code> oppure <code class="language-plaintext highlighter-rouge">role: "customer"</code>.</p>

<p>Meglio, ma codifica un presupposto falso: che essere fornitore o cliente sia una proprietà permanente di una persona. Non lo è. È una proprietà <em>di una specifica relazione</em>. La stessa persona è fornitore nella prenotazione in cui insegna chitarra e cliente in quella in cui chiama l’idraulico. Una singola colonna <code class="language-plaintext highlighter-rouge">role</code> non riesce a esprimerlo, e ti ritroverai a combatterci contro.</p>

<p><strong>Opzione C — il ruolo emerge dall’associazione.</strong> Un solo modello <code class="language-plaintext highlighter-rouge">User</code>, nessuna colonna ruolo. Sei fornitore <em>dei servizi che hai creato</em> e cliente <em>delle prenotazioni che hai effettuato</em>.</p>

<p>È quella che useremo:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_many</span> <span class="ss">:services</span>                                        <span class="c1"># ciò che offro</span>
  <span class="n">has_many</span> <span class="ss">:bookings</span><span class="p">,</span> <span class="ss">foreign_key: :customer_id</span>             <span class="c1"># ciò che ho prenotato</span>
  <span class="n">has_many</span> <span class="ss">:received_bookings</span><span class="p">,</span> <span class="ss">through: :services</span><span class="p">,</span> <span class="ss">source: :bookings</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Nessuna duplicazione, un solo login, e un utente che fa entrambe le cose diventa il caso normale invece che l’eccezione. Quando più avanti ci servirà un comportamento riservato a chi offre servizi, sarà una domanda sui dati (<code class="language-plaintext highlighter-rouge">user.services.any?</code>), non su un flag da tenere sincronizzato.</p>

<p>Per onestà sul compromesso: questo approccio rende alcune query leggermente più articolate. “Mostrami tutto ciò che succede nel mio account” deve guardare due associazioni invece di una. In cambio, non dovremo mai rispondere alla domanda “cosa succede quando un cliente diventa fornitore” — perché non succede nulla. È un buon affare.</p>

<h3 id="le-prenotazioni-contengono-il-prezzo">Le prenotazioni contengono il prezzo</h3>

<p>Una <code class="language-plaintext highlighter-rouge">Booking</code> non è solo un collegamento tra un utente e un servizio. È il registro di un accordo in un preciso momento: la data, il prezzo concordato, lo stato. Ha bisogno di una propria colonna prezzo invece di leggere <code class="language-plaintext highlighter-rouge">service.price</code>, perché il prezzo del servizio può cambiare domani e questo non deve riscrivere silenziosamente ciò che qualcuno aveva accettato di pagare la settimana scorsa.</p>

<p>È il genere di cosa facile da sbagliare e dolorosa da correggere quando ci sono dati reali, ed è il motivo per cui la decidiamo prima di scrivere una sola migrazione.</p>

<h3 id="le-recensioni-appartengono-alle-prenotazioni-non-ai-servizi">Le recensioni appartengono alle prenotazioni, non ai servizi</h3>

<p>Verrebbe naturale agganciare una <code class="language-plaintext highlighter-rouge">Review</code> direttamente a un <code class="language-plaintext highlighter-rouge">Service</code>. Agganciarla invece a una <code class="language-plaintext highlighter-rouge">Booking</code> ci dà gratis una cosa preziosa: solo chi ha effettivamente prenotato e completato un servizio può recensirlo. Il vincolo è incorporato nella forma dei dati, invece di essere affidato a una logica di validazione che dobbiamo ricordarci di scrivere.</p>

<h2 id="una-pagina-di-presentazione">Una pagina di presentazione</h2>

<p>Per chiudere con qualcosa di visibile, una home page statica:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate controller Pages home <span class="nt">--skip-routes</span> <span class="nt">--no-helper</span> <span class="nt">--no-assets</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">root</span> <span class="s2">"pages#home"</span>
</code></pre></div></div>

<p>Ho usato <code class="language-plaintext highlighter-rouge">--skip-routes</code> perché altrimenti il generatore aggiungerebbe <code class="language-plaintext highlighter-rouge">get "pages/home"</code>, mentre noi la vogliamo sulla radice. <code class="language-plaintext highlighter-rouge">--no-helper</code> e <code class="language-plaintext highlighter-rouge">--no-assets</code> servono solo a evitare la creazione di file che non useremo.</p>

<p>La view è semplice markup che descrive il progetto, con due pulsanti volutamente inerti: segnaposto per i flussi che costruiremo dopo.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Apri <code class="language-plaintext highlighter-rouge">http://127.0.0.1:3000</code> ed eccola: un’applicazione vuota che sa cosa vuole diventare.</p>

<h2 id="cosa-viene-dopo">Cosa viene dopo</h2>

<p>L’episodio 2 trasforma lo schizzo del dominio in codice vero: il modello <code class="language-plaintext highlighter-rouge">User</code>, l’autenticazione e le prime migrazioni. Da lì il marketplace inizia a prendere forma — servizi, categorie e i flussi che collegano i due lati di una prenotazione.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Questo è il primo episodio di VicinoTe, una serie in cui costruiamo un’applicazione Rails completa partendo da una cartella vuota, fino ad arrivare — negli episodi finali — a un modulo di AI basato su RubyLLM.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/vicinote-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/vicinote-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="it"><title type="html">Collegare RubyLLM a Rails: un form di chat minimale</title><link href="https://antoninoscaffidi.github.io/it/wiring-rubyllm-into-rails/" rel="alternate" type="text/html" title="Collegare RubyLLM a Rails: un form di chat minimale" /><published>2026-08-07T10:00:00+00:00</published><updated>2026-08-07T10:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/wiring-rubyllm-into-rails.it</id><content type="html" xml:base="https://antoninoscaffidi.github.io/wiring-rubyllm-into-rails/"><![CDATA[<p>Nell’<a href="/it/introduction-to-rubyllm/">episodio 1</a> abbiamo installato RubyLLM e fatto una singola chiamata da Ruby puro. Questa volta lo colleghiamo a una vera app Rails: un form dove scrivi un messaggio e ricevi una risposta dal modello. Nessun database, nessuna cronologia della conversazione ancora — quella è l’episodio 3. L’obiettivo qui è solo vedere RubyLLM funzionare dentro un vero ciclo richiesta/risposta di Rails.</p>

<p>Il codice completo di questo episodio è su GitHub, taggato <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo/tree/episode-2"><code class="language-plaintext highlighter-rouge">episode-2</code></a>, in un nuovo repo di accompagnamento — <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo">ai-with-ruby-demo</a> — che crescerà a ogni post di questa serie.</p>

<h2 id="configurazione-dellapp">Configurazione dell’app</h2>

<p>Una nuova app Rails 8 con Tailwind CSS:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails new ai-with-ruby-demo <span class="nt">--css</span> tailwind
</code></pre></div></div>

<p>Due gemme nel <code class="language-plaintext highlighter-rouge">Gemfile</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">gem</span> <span class="s2">"ruby_llm"</span>

<span class="n">group</span> <span class="ss">:development</span> <span class="k">do</span>
  <span class="n">gem</span> <span class="s2">"dotenv-rails"</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ruby_llm</code> è la libreria vera e propria. <code class="language-plaintext highlighter-rouge">dotenv-rails</code> carica un file <code class="language-plaintext highlighter-rouge">.env</code> dentro <code class="language-plaintext highlighter-rouge">ENV</code> in development — Rails non lo fa da solo.</p>

<h2 id="tenere-la-api-key-fuori-da-git">Tenere la API key fuori da git</h2>

<p>Il <code class="language-plaintext highlighter-rouge">.gitignore</code> di default di Rails 8 esclude già <code class="language-plaintext highlighter-rouge">.env*</code>, quindi un file <code class="language-plaintext highlighter-rouge">.env</code> con la tua chiave reale non viene mai committato. Committiamo invece un <code class="language-plaintext highlighter-rouge">.env.example</code>, come modello per chi clona il repo:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>OPENAI_API_KEY=sk-your-key-here
</code></pre></div></div>

<p>Copialo in locale e inserisci la tua chiave reale:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cp</span> .env.example .env
</code></pre></div></div>

<h2 id="configurare-rubyllm">Configurare RubyLLM</h2>

<p>I file dentro <code class="language-plaintext highlighter-rouge">config/initializers/</code> vengono eseguiti una sola volta, all’avvio, prima che qualsiasi richiesta venga gestita — il posto giusto per configurare una gemma come questa:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/ruby_llm.rb</span>
<span class="no">RubyLLM</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">openai_api_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"OPENAI_API_KEY"</span><span class="p">,</span> <span class="kp">nil</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="rotte-e-controller">Rotte e controller</h2>

<p>Un <code class="language-plaintext highlighter-rouge">resource</code> (singolare) è adatto qui — non c’è una lista di chat da elencare o una specifica da recuperare per ID, solo un unico form:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resource</span> <span class="ss">:chat</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span>
<span class="n">root</span> <span class="s2">"chats#new"</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/chats_controller.rb</span>
<span class="k">class</span> <span class="nc">ChatsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">new</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@message</span> <span class="o">=</span> <span class="n">params</span><span class="p">[</span><span class="ss">:message</span><span class="p">]</span>
    <span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">chat</span>
    <span class="vi">@response</span> <span class="o">=</span> <span class="n">chat</span><span class="p">.</span><span class="nf">ask</span><span class="p">(</span><span class="vi">@message</span><span class="p">).</span><span class="nf">content</span>
    <span class="n">render</span> <span class="ss">:new</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">RubyLLM.chat</code> crea una nuova sessione di chat in memoria — nulla viene salvato da nessuna parte, ed è proprio per questo che la conversazione sparisce con un refresh, per ora. <code class="language-plaintext highlighter-rouge">.ask</code> invia il messaggio e blocca l’esecuzione finché il modello non risponde; <code class="language-plaintext highlighter-rouge">.content</code> è il testo della risposta.</p>

<h2 id="la-view">La view</h2>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">form_with</span> <span class="ss">url: </span><span class="n">chat_path</span><span class="p">,</span> <span class="ss">method: :post</span><span class="p">,</span> <span class="ss">data: </span><span class="p">{</span> <span class="ss">turbo: </span><span class="kp">false</span> <span class="p">}</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;textarea</span> <span class="na">name=</span><span class="s">"message"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="vi">@message</span> <span class="cp">%&gt;</span><span class="nt">&lt;/textarea&gt;</span>
  <span class="nt">&lt;button</span> <span class="na">type=</span><span class="s">"submit"</span><span class="nt">&gt;</span>Send<span class="nt">&lt;/button&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>

<span class="cp">&lt;%</span> <span class="k">if</span> <span class="vi">@response</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;p&gt;</span><span class="cp">&lt;%=</span> <span class="vi">@response</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<h2 id="il-problema-con-turbo">Il problema con Turbo</h2>

<p>La prima versione di questo form non aveva <code class="language-plaintext highlighter-rouge">data: { turbo: false }</code>, e inviarlo non faceva… nulla di visibile. I log del server mostravano un pulito <code class="language-plaintext highlighter-rouge">200 OK</code>, ma la pagina non si aggiornava mai.</p>

<p>La causa: Rails 8 include di default Turbo Drive, che intercetta ogni invio di form e lo manda come richiesta in background, aspettandosi o un redirect o una risposta nel formato Turbo Stream. Il nostro controller renderizzava semplice HTML — Turbo lo riceveva ma non sapeva cosa farci, quindi non faceva nulla silenziosamente.</p>

<p><code class="language-plaintext highlighter-rouge">data: { turbo: false }</code> dice a Turbo di lasciare stare questo form specifico e inviarlo alla vecchia maniera: un vero caricamento di pagina. È la soluzione giusta per ora. Quando arriveremo alle risposte in streaming più avanti in questa serie, vorremo davvero usare Turbo Streams — ma per una prima versione “funziona o no”, disabilitarlo è la strada più semplice.</p>

<h2 id="cosa-viene-dopo">Cosa viene dopo</h2>

<p>L’episodio 3 aggiunge la persistenza: modelli <code class="language-plaintext highlighter-rouge">Conversation</code> e <code class="language-plaintext highlighter-rouge">Message</code>, così la cronologia della chat sopravvive a un refresh della pagina invece di vivere solo dentro una singola richiesta.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Nell’episodio 1 abbiamo installato RubyLLM e fatto una singola chiamata da Ruby puro. Questa volta lo colleghiamo a una vera app Rails: un form dove scrivi un messaggio e ricevi una risposta dal modello. Nessun database, nessuna cronologia della conversazione ancora — quella è l’episodio 3. L’obiettivo qui è solo vedere RubyLLM funzionare dentro un vero ciclo richiesta/risposta di Rails.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>