VicinoTe: Prenotare un servizio
L’episodio 3 aveva portato un utente autenticato al punto di elencare qualcosa che offre. Nessuno però poteva davvero prenotarlo — non esisteva una pagina per il singolo servizio, né un record dell’accordo tra due persone. Questo episodio chiude entrambe le lacune: Service ottiene finalmente una pagina show, e un nuovo modello Booking è il record di un utente che accetta di pagare un altro per qualcosa, in un giorno specifico, a un prezzo specifico.
Il codice è taggato episode-4 nel repo vicinote-tutorial.
Cosa tocca questo episodio, in sintesi
db/migrate/..._create_bookings.rb nuovo — la tabella bookings
app/models/booking.rb nuovo — Booking: belongs_to :service/:customer, validazioni
app/models/concerns/priceable.rb nuovo — l'accessor euro<->centesimi, estratto perché Booking lo condivida
app/models/service.rb modifica — has_many :bookings, usa Priceable
app/models/user.rb modifica — aggiunge has_many :bookings e :received_bookings
app/controllers/services_controller.rb modifica — aggiunge #show
app/controllers/bookings_controller.rb nuovo — #create e #index
app/views/services/show.html.erb nuovo — dettagli del servizio più il form di prenotazione
app/views/services/index.html.erb modifica — i titoli dei servizi ora linkano alla loro pagina show
app/views/bookings/index.html.erb nuovo — "Le mie prenotazioni", divise tra prenotate e ricevute
app/views/layouts/application.html.erb modifica — un link "My bookings" nella nav
config/routes.rb modifica — services show, bookings#create annidata, bookings#index
Generare il modello
bin/rails generate model Booking service:references customer:references price_cents:integer status:string scheduled_on:date
service:references si comporta esattamente come per Service nell’episodio 3 — una colonna di foreign key, un indice, e un belongs_to :service scritto direttamente nel modello generato. customer:references fa la stessa cosa, ma in modo sbagliato: in questa app non esiste un modello Customer. “Customer” è un ruolo che uno User gioca in una data prenotazione, non una classe a sé — la stessa decisione “il ruolo emerge dall’associazione” che l’episodio 1 aveva preso per i fornitori di Service, applicata qui all’altro lato di una prenotazione.
Il generator non ha modo di saperlo, e indovina in base al solo nome. Quello che ha effettivamente prodotto:
# generato, prima della modifica
t.references :customer, null: false, foreign_key: true
# generato, prima della modifica
belongs_to :customer
Lasciata così, la migrazione avrebbe provato ad aggiungere una foreign key verso una tabella customers che non esisterà mai, e il modello avrebbe cercato una classe Customer altrettanto inesistente — entrambe avrebbero fallito nel momento esatto in cui una delle due righe fosse davvero girata. Entrambe hanno avuto bisogno di una correzione esplicita.
La migrazione, corretta
# db/migrate/..._create_bookings.rb
class CreateBookings < ActiveRecord::Migration[8.1]
def change
create_table :bookings do |t|
t.references :service, null: false, foreign_key: true
t.references :customer, null: false, foreign_key: { to_table: :users }
t.integer :price_cents, null: false
t.string :status, null: false, default: "pending"
t.date :scheduled_on, null: false
t.timestamps
end
end
end
t.references :customer, null: false, foreign_key: { to_table: :users }— la correzione dell’ipotesi sbagliata.foreign_key: trueda solo deduce la tabella di destinazione dal nome stesso della reference (customer→customers); passare un hash conto_table:sovrascrive quella deduzione e punta il vincolo alla tabella reale,users, mentre la colonna stessa resta chiamatacustomer_id— il nome che conta davvero per il codice che la legge.t.integer :price_cents, null: false— nessun default qui, deliberatamente. Una prenotazione ha sempre un prezzo concordato; non c’è un valore ragionevole su cui ricadere se non viene fornito.t.string :status, null: false, default: "pending"— ogni prenotazione parte dallo stesso stato. Questo episodio non lo cambia mai — nulla qui costruisce un’azione “conferma” o “annulla” — ma la colonna, e l’insieme di valori che può contenere (imposto nel modello sotto), sono già al loro posto per quando servirà.t.date :scheduled_on, null: false— l’unica informazione che il cliente fornisce davvero: per quale giorno è.
Condividere l’accessor del prezzo: il concern Priceable
L’episodio 3 aveva dato a Service una piccola coppia price/price= per parlare in euro mentre price_cents resta ciò che viene salvato. Booking ha bisogno esattamente dello stesso comportamento — ha anche lui la propria colonna price_cents adesso. Copiare quelle quattro righe una seconda volta funzionerebbe, ma due copie indipendenti della stessa logica tendono a divergere nel momento in cui una delle due ha bisogno di una correzione e l’altra viene dimenticata. È esattamente per questo che esiste ActiveSupport::Concern:
# app/models/concerns/priceable.rb
module Priceable
extend ActiveSupport::Concern
def price
price_cents && price_cents / 100.0
end
def price=(value)
self.price_cents = value.present? ? (value.to_f * 100).round : nil
end
end
Nulla in questo codice è cambiato rispetto all’episodio 3 — sono gli stessi due metodi, spostati parola per parola dentro Service in un file proprio sotto app/models/concerns/, una cartella che Rails autocarica per convenzione senza bisogno di alcun require. extend ActiveSupport::Concern è ciò che fa comportare include Priceable in modo prevedibile anche se il concern dovesse in futuro crescere con un blocco included do ... end o con propri metodi di classe — per un modulo semplice come questo non è strettamente necessario, ma è la forma standard che assume un concern Rails, e usarla sempre significa non dover mai ricordare quali moduli hanno bisogno del trattamento speciale e quali no.
# app/models/service.rb
class Service < ApplicationRecord
include Priceable
belongs_to :user
belongs_to :category
has_many :bookings
validates :title, presence: true
validates :description, presence: true
validates :price_cents, presence: true, numericality: { greater_than: 0, only_integer: true }
end
Service ha perso i suoi due metodi per il prezzo e ha guadagnato include Priceable e has_many :bookings al loro posto — l’altra metà di Booking belongs_to :service, necessaria nel momento in cui qualcosa vuole chiedere a un servizio quali prenotazioni sono state fatte contro di esso.
Il modello Booking
# app/models/booking.rb
class Booking < ApplicationRecord
include Priceable
belongs_to :service
belongs_to :customer, class_name: "User"
validates :scheduled_on, presence: true
validates :status, presence: true, inclusion: { in: %w[pending confirmed completed cancelled] }
validates :price_cents, presence: true, numericality: { greater_than: 0, only_integer: true }
validate :customer_is_not_the_provider
private
def customer_is_not_the_provider
return unless service && customer_id
errors.add(:customer, "can't book their own service") if service.user_id == customer_id
end
end
belongs_to :customer, class_name: "User"— la metà lato modello della stessa correzione richiesta dalla migrazione.class_name: "User"dice a Rails “l’associazione si chiamacustomer, ma la classe reale dall’altra parte èUser” — senza, Rails proverebbe a fare il constantize di"Customer"e fallirebbe. La foreign key (customer_id) viene comunque dedotta correttamente dal nome dell’associazione; solo la classe aveva bisogno di essere specificata.validates :status, ..., inclusion: { in: %w[pending confirmed completed cancelled] }— la colonna del database è una semplice stringa senza alcun vincolo proprio oltre anull: false; è questa validazione a impedire davvero che unBookingpossa mai contenere un refuso o uno stato inventato che Rails non riconosce. Solo"pending"è raggiungibile con qualsiasi cosa costruita in questo episodio, ma l’insieme completo viene dichiarato fin da ora invece di crescere una stringa alla volta più avanti.validate :customer_is_not_the_provider— una validazione che non controlla una colonna contro una regola fissa, ma un’associazione contro un’altra.return unless service && customer_idprotegge dall’eseguire il confronto quando uno dei due lati manca ancora (unBookingnuovo di zecca e vuoto non dovrebbe produrre proprio questo errore solo perché non è stato ancora compilato nulla — le validazioni di presenza sopra coprono già quel caso). Il controllo vero e proprio,service.user_id == customer_id, è ciò che trasforma “non puoi prenotare il tuo stesso servizio” da una frase nel documento di design dell’episodio 1 in qualcosa che il database non potrà mai contenere davvero.
Da notare cosa non c’è qui: nessun before_validation che copia service.price_cents nella prenotazione automaticamente. È deliberato, e il ragionamento è nel controller subito dopo — lo scatto del prezzo avviene dove è visibile, non nascosto dentro una callback.
Collegare le associazioni che l’episodio 1 aveva solo schizzato
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :services, dependent: :destroy
has_many :bookings, foreign_key: :customer_id, dependent: :destroy, inverse_of: :customer
has_many :received_bookings, through: :services, source: :bookings
normalizes :email_address, with: ->(e) { e.strip.downcase }
end
Il design di dominio dell’episodio 1 mostrava entrambe queste righe come illustrazione di “un solo User, due ruoli che emergono dalle associazioni, non una colonna di ruolo.” L’episodio 3 aveva già collegato has_many :services; queste due sono quelle rimaste solo uno schizzo fino ad ora.
has_many :bookings, foreign_key: :customer_id, dependent: :destroy, inverse_of: :customer— senzaforeign_key:, Rails cercherebbe una colonnauser_idsubookings, che non esiste; la colonna reale ècustomer_id, quindi va nominata esplicitamente.inverse_of: :customerdice a Rails che questa associazione eBooking belongs_to :customersono due facce della stessa relazione, il che permette a Rails di saltare una query ridondante quando ha già un lato caricato in memoria e ha bisogno dell’altro.dependent: :destroycorrisponde alla stessa policy già applicata asessionseservices: eliminare unoUsernon dovrebbe lasciare righeBookingche puntano a uncustomer_idche non esiste più.has_many :received_bookings, through: :services, source: :bookings— unhas_many :through, non una foreign key diretta suusers. Si legge da destra a sinistra: per ognuno deiservicesdi questo utente, segui l’associazionebookingspropria di quel servizio, e tratta il risultato combinato come ireceived_bookingsdi questo utente.source: :bookingsserve solo perché il nome dell’associazione da questo lato (received_bookings) non corrisponde al nome dell’associazione che viene seguita suService(bookings) — senza, Rails cercherebbe un metodoreceived_bookingssuService, che non esiste, invece di quello reale,bookings.
La distinzione tra queste due conta: user.bookings è “cosa ho prenotato, come cliente” — una relazione diretta. user.received_bookings è “cosa è stato prenotato da me, su tutti i servizi che offro” — una relazione che esiste solo passando per una seconda tabella intermedia. Stessa tabella bookings sottostante, due domande genuinamente diverse.
Finalmente: una pagina per un singolo servizio
# app/controllers/services_controller.rb
class ServicesController < ApplicationController
allow_unauthenticated_access only: %i[index show]
def index
@services = Service.includes(:category, :user).order(created_at: :desc)
end
def show
@service = Service.includes(:category, :user).find(params[:id])
@booking = Booking.new
end
# new/create invariati dall'episodio 3
end
allow_unauthenticated_access only: %i[index show] — l’episodio 3 esentava solo index dall’obbligo di accesso; show lo raggiunge qui, per lo stesso motivo: sfogliare un singolo annuncio deve funzionare anche per un visitatore che non si è mai registrato, altrimenti il marketplace è visibile solo a chi si è già impegnato a usarlo. @booking = Booking.new costruisce una Booking vuota e non salvata puramente perché il form_with model: @booking della view ha bisogno di qualcosa attorno a cui costruire un form — nulla qui la salva o compila anche solo uno dei suoi attributi ancora.
# config/routes.rb
resources :services, only: [:index, :new, :create, :show] do
resources :bookings, only: [:create]
end
resources :bookings, only: [:index]
Prefix Verb URI Pattern Controller#Action
service_bookings POST /services/:service_id/bookings(.:format) bookings#create
bookings GET /bookings(.:format) bookings#index
Annidare resources :bookings, only: [:create] dentro resources :services è ciò che produce POST /services/:service_id/bookings e l’helper service_bookings_path(service) usato nella view più sotto — è l’URL stesso a dire per quale servizio è una nuova prenotazione, così il controller non deve mai indovinarlo da nessun’altra parte. Il secondo resources :bookings, only: [:index], non annidato, è separato di proposito: “elenco delle mie prenotazioni” non è ristretto a un singolo servizio, quindi non appartiene affatto sotto /services/:service_id/.
Prenotare, e dove il prezzo viene davvero copiato
# app/controllers/bookings_controller.rb
class BookingsController < ApplicationController
def index
@my_bookings = Current.user.bookings.includes(:service).order(scheduled_on: :asc)
@received_bookings = Current.user.received_bookings.includes(:service, :customer).order(scheduled_on: :asc)
end
def create
service = Service.find(params[:service_id])
booking = Current.user.bookings.new(booking_params.merge(service: service, price_cents: service.price_cents))
if booking.save
redirect_to bookings_path, notice: "Booked for #{booking.scheduled_on}."
else
redirect_to service_path(service), alert: booking.errors.full_messages.to_sentence
end
end
private
def booking_params
params.require(:booking).permit(:scheduled_on)
end
end
Nessun allow_unauthenticated_access da nessuna parte in questo controller — entrambe le azioni restano dietro l’obbligo di accesso di default, correttamente: nessuno dovrebbe poter elencare o fare una prenotazione in anonimo.
def index— due query separate assegnate a due variabili di istanza separate, una per ciascun lato in cui un utente autenticato potrebbe trovarsi in una prenotazione.Current.user.bookingsusa l’associazione diretta;Current.user.received_bookingsusa quellahas_many :through. Entrambe ricevonoincludes(...)per lo stesso motivo dell’index dei servizi nell’episodio 3: renderizzare un elenco di prenotazioni e leggerebooking.service.title(obooking.customer.email_address) per ognuna farebbe altrimenti scattare una query in più per ogni riga.service = Service.find(params[:service_id])—:service_id, non:id, per via della route annidata: una richiesta aPOST /services/7/bookingsporta l’id del servizio sotto quella chiave, non quello della prenotazione (che ancora non esiste).Current.user.bookings.new(booking_params.merge(service: service, price_cents: service.price_cents))— questa singola riga è l’intero meccanismo dello scatto del prezzo del design dell’episodio 1, reso concreto.booking_paramscontiene soloscheduled_on— l’unico campo che il form invia davvero — quindiprice_cents: service.price_centsdeve essere aggiunto esplicitamente, qui, dal controller, che legge il prezzo attuale del servizio nell’esatto momento in cui qualcuno si impegna a prenotarlo. Nulla collega laBookingrisultante aService#pricedopo che questa riga è girata; se il fornitore alza il prezzo domani, ogni prenotazione fatta oggi mantiene il numero che era vero quando è stata fatta.Current.user.bookings.new(...), costruendo attraverso l’associazione invece diBooking.new(customer: Current.user, ...), è anche ciò che fissa il lato cliente a chiunque sia effettivamente autenticato — non c’è nessuncustomer_idda nessuna parte inbooking_paramsche un visitatore potrebbe manomettere.if booking.save/ i due rami — in caso di successo, redirect all’elenco delle prenotazioni con un messaggio flash, la stessa forma Post/Redirect/Get usata da ogni azione di scrittura in questa serie fin dall’episodio 2. In caso di fallimento — qualcuno ha provato a prenotare il proprio stesso servizio, oppure la data è stata lasciata vuota — non c’è una viewbookings/newdedicata da ri-renderizzare con gli errori inline, perché il form vive sulla pagina di qualcun altro (loshowdel servizio), non sulla propria. Reindirizzare aservice_path(service)conalert: booking.errors.full_messages.to_sentenceè l’adattamento più semplice a quella forma: gli errori compaiono come un unico messaggio flash invece che campo per campo, un vero compromesso (feedback meno preciso) fatto deliberatamente invece di forzare unrendertra controller diversi per evitarlo.
La pagina del servizio e il suo form di prenotazione
<%# app/views/services/show.html.erb %>
<div class="max-w-2xl mx-auto w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-700 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-600"><%= @service.category.name %></p>
<div class="mt-1 flex items-start justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900"><%= @service.title %></h1>
<p class="whitespace-nowrap text-2xl font-semibold text-gray-900"><%= number_to_currency(@service.price) %></p>
</div>
<p class="mt-1 text-sm text-gray-500">by <%= @service.user.email_address %></p>
<p class="mt-4 text-gray-600"><%= @service.description %></p>
<hr class="my-8 border-gray-200">
<% if !authenticated? %>
<p class="text-gray-600">
<%= link_to "Sign in", new_session_path, class: "text-indigo-600 hover:underline" %> to book this service.
</p>
<% elsif Current.user == @service.user %>
<p class="text-gray-500">This is your own service.</p>
<% else %>
<h2 class="text-lg font-semibold text-gray-900 mb-3">Book this service</h2>
<%= form_with model: @booking, url: service_bookings_path(@service), class: "flex items-end gap-3" do |form| %>
<div>
<%= form.label :scheduled_on, "Date", class: "block text-sm font-medium text-gray-700" %>
<%= form.date_field :scheduled_on, class: "mt-1 block rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" %>
</div>
<%= form.submit "Book for #{number_to_currency(@service.price)}", class: "rounded-md bg-indigo-600 px-4 py-2 text-white font-medium hover:bg-indigo-700 cursor-pointer" %>
<% end %>
<% end %>
</div>
<% if !authenticated? %> ... <% elsif Current.user == @service.user %> ... <% else %> ... <% end %>— un ramo a tre vie su chi sta guardando questa pagina: un visitatore anonimo (invitato ad accedere, invece di vedersi mostrare un form che fallirebbe comunque lato server), il fornitore stesso (a cui viene detto chiaramente che questo è il suo annuncio, dato che prenotare il proprio servizio non ha senso e il controller lo rifiuterebbe comunque), oppure chiunque altro (il vero form di prenotazione). La validazionecustomer_is_not_the_providerdel modello è l’imposizione reale; nascondere il form qui è la metà rivolta all’utente della stessa regola, non un sostituto — il classico abbinamento comodità-lato-client più verità-lato-server.form_with model: @booking, url: service_bookings_path(@service), ...—service_bookings_path(@service)è l’helper della route annidata, che produce/services/7/bookingsper il servizio7; passare@service(un oggetto ActiveRecord) invece del suoidlascia che Rails chiami.to_paramsu di esso, che per una chiave primariaidsemplice e non modificata restituisce semplicemente l’id stesso.form.date_field :scheduled_on— un input HTML5 nativo di tipo data, che ottiene gratuitamente un selettore in stile calendario in ogni browser moderno, senza una riga di JavaScript scritta per questo.form.submit "Book for #{number_to_currency(@service.price)}", ...— l’etichetta stessa del bottone dichiara il prezzo esatto a cui ci si sta impegnando, calcolato allo stesso modo della visualizzazione del prezzo più in alto nella pagina, così nessuno invia senza aver appena visto il numero che sta per accettare.
L’elenco delle prenotazioni
<%# app/views/bookings/index.html.erb %>
<div class="max-w-2xl mx-auto w-full">
<% if notice = flash[:notice] %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-700 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<h1 class="text-2xl font-bold text-gray-900 mb-6">My bookings</h1>
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-3">Services you've booked</h2>
<% if @my_bookings.none? %>
<p class="text-gray-500 mb-8">You haven't booked anything yet.</p>
<% else %>
<div class="space-y-3 mb-8">
<% @my_bookings.each do |booking| %>
<div class="rounded-lg border border-gray-200 p-4 flex items-center justify-between">
<div>
<p class="font-medium text-gray-900"><%= booking.service.title %></p>
<p class="text-sm text-gray-500"><%= booking.scheduled_on.strftime("%-d %B %Y") %> · <%= booking.status %></p>
</div>
<p class="font-semibold text-gray-900"><%= number_to_currency(booking.price) %></p>
</div>
<% end %>
</div>
<% end %>
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-3">Bookings received</h2>
<% if @received_bookings.none? %>
<p class="text-gray-500">Nobody has booked one of your services yet.</p>
<% else %>
<div class="space-y-3">
<% @received_bookings.each do |booking| %>
<div class="rounded-lg border border-gray-200 p-4 flex items-center justify-between">
<div>
<p class="font-medium text-gray-900"><%= booking.service.title %></p>
<p class="text-sm text-gray-500">
<%= booking.scheduled_on.strftime("%-d %B %Y") %> · <%= booking.status %> · booked by <%= booking.customer.email_address %>
</p>
</div>
<p class="font-semibold text-gray-900"><%= number_to_currency(booking.price) %></p>
</div>
<% end %>
</div>
<% end %>
</div>
Due elenchi indipendenti su un’unica pagina, ciascuno con il proprio stato vuoto, che rispecchiano esattamente le due variabili di istanza separate del controller — @my_bookings si renderizza come “servizi che hai prenotato”, @received_bookings come “prenotazioni ricevute”. booking.price, non booking.service.price — ogni numero su questa pagina è il prezzo congelato al momento della prenotazione, deliberatamente mai quello attuale del servizio. booking.customer.email_address compare solo nell’elenco ricevute — sai sempre chi ha prenotato da te; non hai bisogno di ricordare chi sei tu nel tuo stesso elenco delle prenotazioni fatte.
Provarlo, senza browser
Il solito giro con bin/dev funziona anche qui — accedi come un utente, visita un servizio elencato da qualcun altro, scegli una data, prenota, poi controlla /bookings sia come cliente sia, accedendo come fornitore, dal lato ricevente. La verifica reale di questo episodio è avvenuta in un modo diverso, direttamente con bin/rails runner, apposta per dimostrare il comportamento che conta di più ed è il più difficile da vedere a occhio da uno screenshot — che il prezzo di una prenotazione non si muove davvero mai dopo che è stata fatta:
maria = User.find_by(email_address: "maria@example.com")
service = maria.services.first
customer = User.find_or_create_by!(email_address: "luca@example.com") { |u| u.password = "supersecret123" }
booking = customer.bookings.new(service: service, scheduled_on: Date.tomorrow, price_cents: service.price_cents)
booking.save!
puts "Booked at: #{booking.price}"
service.update!(price: 99.99)
puts "Service price now: #{service.reload.price}, booking price still: #{booking.reload.price}"
Booked at: 42.5
Service price now: 99.99, booking price still: 42.5
Insieme a questo, provare maria.bookings.new(service: service, ...) — Maria che prova a prenotare le proprie stesse lezioni di chitarra — ha restituito valid? false, con esattamente il messaggio che il modello era stato scritto per produrre: "Customer can't book their own service".
Cosa viene dopo
L’episodio 5 copre Review — lasciata dopo che una prenotazione è completata, appartenente alla prenotazione stessa piuttosto che al servizio, esattamente come aveva deciso il design dell’episodio 1: solo chi ha davvero portato a termine una prenotazione può lasciarne una.