diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..24d4ddf
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,18 @@
+# Copy to .env and fill in what you need. Not required for basic local dev.
+# See README.md for what's required in production.
+
+# SECRET_KEY_BASE=
+# PRIMARY_DATABASE_URL= # not needed if deploying from the Rails 8 Railway template
+# CACHE_DATABASE_URL= # not needed if deploying from the Rails 8 Railway template
+# QUEUE_DATABASE_URL= # not needed if deploying from the Rails 8 Railway template
+# CABLE_DATABASE_URL= # not needed if deploying from the Rails 8 Railway template
+# APP_HOST=
+# POSTMARK_API_TOKEN=
+# MAIL_PROVIDER= # "postmark" (default) or "brevo" (backup)
+# BREVO_API_KEY=
+# MAIL_FROM_ADDRESS= # defaults to noreply@rockymtnruby.dev; must be a sender verified with whichever MAIL_PROVIDER is active
+# MISSION_CONTROL_USER=
+# MISSION_CONTROL_PASSWORD=
+# TITO_API_TOKEN=
+# TITO_ACCOUNT_SLUG=
+# TITO_EVENT_SLUG=
diff --git a/.gitignore b/.gitignore
index fbcab40..b064a69 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,8 +7,9 @@
# Ignore bundler config.
/.bundle
-# Ignore all environment files.
+# Ignore all environment files, except the example.
/.env*
+!/.env.example
# Ignore all logfiles and tempfiles.
/log/*
diff --git a/Gemfile b/Gemfile
index 8c5fd94..455676b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,6 +2,8 @@ source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
+# Pin below the 3.x major bump until app/test compatibility with it is verified
+gem "json", "~> 2.19"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use postgresql as the database for Active Record
@@ -47,11 +49,17 @@ gem "tito_ruby"
# Postmark for transactional email in production
gem "postmark-rails"
+# Brevo for backup transactional email. Its API client is used instead of an
+# SMTP delivery method because Brevo's SMTP relay requires a separate,
+# support-activated gate on new accounts that the API doesn't have.
+gem "brevo"
+
# PDF generation for the passport application form
gem "prawn"
gem "prawn-table"
group :development, :test do
+ gem "dotenv-rails"
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
diff --git a/Gemfile.lock b/Gemfile.lock
index 56885f6..2ba6027 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,31 +1,31 @@
GEM
remote: https://rubygems.org/
specs:
- action_text-trix (2.1.18)
+ action_text-trix (2.1.19)
railties
- actioncable (8.1.3)
- actionpack (= 8.1.3)
- activesupport (= 8.1.3)
+ actioncable (8.1.3.1)
+ actionpack (= 8.1.3.1)
+ activesupport (= 8.1.3.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (8.1.3)
- actionpack (= 8.1.3)
- activejob (= 8.1.3)
- activerecord (= 8.1.3)
- activestorage (= 8.1.3)
- activesupport (= 8.1.3)
+ actionmailbox (8.1.3.1)
+ actionpack (= 8.1.3.1)
+ activejob (= 8.1.3.1)
+ activerecord (= 8.1.3.1)
+ activestorage (= 8.1.3.1)
+ activesupport (= 8.1.3.1)
mail (>= 2.8.0)
- actionmailer (8.1.3)
- actionpack (= 8.1.3)
- actionview (= 8.1.3)
- activejob (= 8.1.3)
- activesupport (= 8.1.3)
+ actionmailer (8.1.3.1)
+ actionpack (= 8.1.3.1)
+ actionview (= 8.1.3.1)
+ activejob (= 8.1.3.1)
+ activesupport (= 8.1.3.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
- actionpack (8.1.3)
- actionview (= 8.1.3)
- activesupport (= 8.1.3)
+ actionpack (8.1.3.1)
+ actionview (= 8.1.3.1)
+ activesupport (= 8.1.3.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
- actiontext (8.1.3)
+ actiontext (8.1.3.1)
action_text-trix (~> 2.1.15)
- actionpack (= 8.1.3)
- activerecord (= 8.1.3)
- activestorage (= 8.1.3)
- activesupport (= 8.1.3)
+ actionpack (= 8.1.3.1)
+ activerecord (= 8.1.3.1)
+ activestorage (= 8.1.3.1)
+ activesupport (= 8.1.3.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (8.1.3)
- activesupport (= 8.1.3)
+ actionview (8.1.3.1)
+ activesupport (= 8.1.3.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- activejob (8.1.3)
- activesupport (= 8.1.3)
+ activejob (8.1.3.1)
+ activesupport (= 8.1.3.1)
globalid (>= 0.3.6)
- activemodel (8.1.3)
- activesupport (= 8.1.3)
- activerecord (8.1.3)
- activemodel (= 8.1.3)
- activesupport (= 8.1.3)
+ activemodel (8.1.3.1)
+ activesupport (= 8.1.3.1)
+ activerecord (8.1.3.1)
+ activemodel (= 8.1.3.1)
+ activesupport (= 8.1.3.1)
timeout (>= 0.4.0)
- activestorage (8.1.3)
- actionpack (= 8.1.3)
- activejob (= 8.1.3)
- activerecord (= 8.1.3)
- activesupport (= 8.1.3)
+ activestorage (8.1.3.1)
+ actionpack (= 8.1.3.1)
+ activejob (= 8.1.3.1)
+ activerecord (= 8.1.3.1)
+ activesupport (= 8.1.3.1)
marcel (~> 1.0)
- activesupport (8.1.3)
+ activesupport (8.1.3.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -81,12 +81,16 @@ GEM
base64 (0.3.0)
bcrypt_pbkdf (1.1.2)
bcrypt_pbkdf (1.1.2-x86_64-darwin)
- bigdecimal (4.1.1)
+ bigdecimal (4.1.3)
bindex (0.8.1)
bootsnap (1.23.0)
msgpack (~> 1.2)
- brakeman (8.0.4)
+ brakeman (8.0.6)
racc
+ brevo (4.0.0)
+ addressable (~> 2.3, >= 2.3.0)
+ json (~> 2.1, >= 2.1.0)
+ typhoeus (~> 1.0, >= 1.0.1)
builder (3.3.0)
bullet (8.1.0)
activesupport (>= 3.0.0)
@@ -105,25 +109,31 @@ GEM
xpath (~> 3.2)
childprocess (5.1.0)
logger (~> 1.5)
- concurrent-ruby (1.3.6)
+ concurrent-ruby (1.3.8)
connection_pool (3.0.2)
- crass (1.0.6)
+ crass (1.0.7)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.2.0)
+ dotenv-rails (3.2.0)
+ dotenv (= 3.2.0)
+ railties (>= 6.1)
drb (2.2.3)
ed25519 (1.4.0)
- erb (6.0.3)
+ erb (6.0.7)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
- faraday (2.14.1)
+ ethon (0.18.0)
+ ffi (>= 1.15.0)
+ logger
+ faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
logger
- faraday-net_http (3.4.2)
+ faraday-net_http (3.4.4)
net-http (~> 0.5)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
@@ -136,9 +146,9 @@ GEM
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
- globalid (1.3.0)
+ globalid (1.4.0)
activesupport (>= 6.1)
- i18n (1.14.8)
+ i18n (1.15.2)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
@@ -147,8 +157,8 @@ GEM
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
- io-console (0.8.2)
- irb (1.17.0)
+ io-console (0.9.2)
+ irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
@@ -156,7 +166,7 @@ GEM
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
- json (2.19.3)
+ json (2.21.2)
kamal (2.11.0)
activesupport (>= 7.0)
base64 (~> 0.2)
@@ -177,21 +187,21 @@ GEM
launchy (>= 2.2, < 4)
lint_roller (1.1.0)
logger (1.7.0)
- loofah (2.25.1)
+ loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
- mail (2.9.0)
+ mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
- marcel (1.1.0)
+ marcel (1.2.1)
matrix (0.4.3)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
- minitest (6.0.4)
+ minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
mission_control-jobs (1.1.0)
@@ -204,15 +214,15 @@ GEM
railties (>= 7.1)
stimulus-rails
turbo-rails
- msgpack (1.8.0)
+ msgpack (1.8.5)
net-http (0.9.1)
uri (>= 0.11.1)
- net-imap (0.6.3)
+ net-imap (0.6.7)
date
net-protocol
net-pop (0.1.2)
net-protocol
- net-protocol (0.2.2)
+ net-protocol (0.3.0)
timeout
net-scp (4.1.0)
net-ssh (>= 2.6.5, < 8.0.0)
@@ -222,21 +232,21 @@ GEM
net-protocol
net-ssh (7.3.2)
nio4r (2.7.5)
- nokogiri (1.19.2-aarch64-linux-gnu)
+ nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-aarch64-linux-musl)
+ nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4)
- nokogiri (1.19.2-arm-linux-gnu)
+ nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-arm-linux-musl)
+ nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4)
- nokogiri (1.19.2-arm64-darwin)
+ nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-darwin)
+ nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-linux-gnu)
+ nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-linux-musl)
+ nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.3)
parallel (2.0.1)
@@ -256,7 +266,7 @@ GEM
postmark-rails (0.22.1)
actionmailer (>= 3.0.0)
postmark (>= 1.21.3, < 2.0)
- pp (0.6.3)
+ pp (0.6.4)
prettyprint
prawn (2.4.0)
pdf-core (~> 0.9.0)
@@ -269,15 +279,12 @@ GEM
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
- psych (5.3.1)
- date
- stringio
public_suffix (7.0.5)
- puma (8.0.0)
+ puma (8.0.2)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
- rack (3.2.6)
+ rack (3.2.7)
rack-mini-profiler (4.0.1)
rack (>= 1.2.0)
rack-session (2.1.2)
@@ -287,30 +294,30 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
- rails (8.1.3)
- actioncable (= 8.1.3)
- actionmailbox (= 8.1.3)
- actionmailer (= 8.1.3)
- actionpack (= 8.1.3)
- actiontext (= 8.1.3)
- actionview (= 8.1.3)
- activejob (= 8.1.3)
- activemodel (= 8.1.3)
- activerecord (= 8.1.3)
- activestorage (= 8.1.3)
- activesupport (= 8.1.3)
+ rails (8.1.3.1)
+ actioncable (= 8.1.3.1)
+ actionmailbox (= 8.1.3.1)
+ actionmailer (= 8.1.3.1)
+ actionpack (= 8.1.3.1)
+ actiontext (= 8.1.3.1)
+ actionview (= 8.1.3.1)
+ activejob (= 8.1.3.1)
+ activemodel (= 8.1.3.1)
+ activerecord (= 8.1.3.1)
+ activestorage (= 8.1.3.1)
+ activesupport (= 8.1.3.1)
bundler (>= 1.15.0)
- railties (= 8.1.3)
+ railties (= 8.1.3.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
- rails-html-sanitizer (1.7.0)
- loofah (~> 2.25)
+ rails-html-sanitizer (1.7.1)
+ loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
- railties (8.1.3)
- actionpack (= 8.1.3)
- activesupport (= 8.1.3)
+ railties (8.1.3.1)
+ actionpack (= 8.1.3.1)
+ activesupport (= 8.1.3.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -319,16 +326,21 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.4.2)
- rdoc (7.2.0)
+ rbs (4.2.0)
+ logger
+ prism (>= 1.6.0)
+ tsort
+ rdoc (8.0.0)
erb
- psych (>= 4.0.0)
+ prism (>= 1.6.0)
+ rbs (>= 4.0.0)
tsort
regexp_parser (2.12.0)
- reline (0.6.3)
+ reline (0.7.0)
io-console (~> 0.5)
rexml (3.4.4)
- rubocop (1.86.1)
- json (~> 2.3)
+ rubocop (1.91.0)
+ json (>= 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (>= 1.10)
@@ -359,7 +371,7 @@ GEM
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
- rubyzip (3.2.2)
+ rubyzip (3.6.0)
securerandom (0.4.1)
selenium-webdriver (4.43.0)
base64 (~> 0.2)
@@ -392,7 +404,6 @@ GEM
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
- stringio (3.2.0)
thor (1.5.0)
thruster (0.1.20)
thruster (0.1.20-aarch64-linux)
@@ -408,6 +419,8 @@ GEM
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
+ typhoeus (1.6.0)
+ ethon (>= 0.18.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
@@ -421,13 +434,13 @@ GEM
bindex (>= 0.4.0)
railties (>= 8.0.0)
websocket (1.2.11)
- websocket-driver (0.8.0)
+ websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
- zeitwerk (2.7.5)
+ zeitwerk (2.8.3)
PLATFORMS
aarch64-linux
@@ -444,13 +457,16 @@ PLATFORMS
DEPENDENCIES
bootsnap
brakeman
+ brevo
bullet
bundler-audit
capybara
debug
+ dotenv-rails
image_processing (~> 1.2)
importmap-rails
jbuilder
+ json (~> 2.19)
kamal
letter_opener
mission_control-jobs
diff --git a/README.md b/README.md
index 7db80e4..d5b8b2e 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,148 @@
-# README
+# Ruby Embassy
-This README would normally document whatever steps are necessary to get the
-application up and running.
+The conference companion app for **Rocky Mountain Ruby 2026** (Mon-Tue, Sep 28-29, eTown
+Hall, Boulder CO): schedule, RSVPs, lightning talks, hack day, and the Ruby Embassy passport
+application flow. It started as the app for Blue Ridge Ruby and was rebranded for RMR.
-Things you may want to cover:
+## Stack
-* Ruby version
+- Ruby 3.4.4 (see `.ruby-version` / `.tool-versions`), Rails 8.1.2
+- Postgres, with Solid Cache / Solid Queue / Solid Cable (no Redis)
+- Hotwire (Turbo + Stimulus), importmap, no Node build step
+- Propshaft for assets, Thruster in front of Puma
+- Prawn for the Ruby Embassy passport PDF
-* System dependencies
+## Third-party services (production)
-* Configuration
+| Service | Used for |
+|---|---|
+| [Railway](https://railway.com) | Hosting, Postgres, deploys |
+| [Postmark](https://postmarkapp.com) | Outbound email (login links) |
+| [Tito](https://ti.to) | Attendee ticketing and login lookup |
-* Database creation
+None of these are needed for local development. See Environment variables below for the
+credentials each one requires.
-* Database initialization
+## Development setup
-* How to run the test suite
+```bash
+bin/setup # bundle install, db:prepare, clear log/tmp, then execs bin/dev
+bin/setup --skip-server # same, but stop before starting the server
+bin/setup --reset # also runs db:reset
+bin/rails db:seed # admin users, schedule, embassy question bank, idempotent
+```
-* Services (job queues, cache servers, search engines, etc.)
+Postgres must already be running locally. Solid Queue runs in-process automatically in
+development (`config/puma.rb:38` enables the Puma plugin whenever `Rails.env.development?`),
+so background jobs, including login emails, work with no extra process to start.
-* Deployment instructions
+### Signing in locally
-* ...
+Auth is a magic link, no password. Seeded admins are defined in `db/seeds.rb`. A plain
+attendee is just:
+
+```ruby
+User.create!(email: "attendee@example.test", first_name: "Testy", last_name: "Attendee")
+```
+
+(`role` defaults to `attendee`, see `app/models/user.rb`.)
+
+To sign in without going through email, mint a login URL directly in `bin/rails console`:
+
+```ruby
+user = User.find_by(email: "attendee@example.test")
+token = user.generate_token_for(:login)
+Rails.application.routes.url_helpers.callback_session_url(
+ token: token, host: "localhost", port: 3000, protocol: "http"
+)
+```
+
+Open that URL and submit the form on it. `SessionsController#callback` only establishes the
+session on POST; a bare GET just renders the confirmation page.
+
+## Tests
+
+```bash
+bin/rails test
+bin/ci # setup, rubocop, bundler-audit, importmap audit, brakeman, tests, seed replant
+```
+
+## Environment variables
+
+Copy `.env.example` to `.env` and fill in what you need (`dotenv-rails` loads it
+automatically in development and test, nothing to source by hand).
+
+| Variable | Dev | Prod |
+|---|---|---|
+| `SECRET_KEY_BASE` | not needed | **Required** |
+| `PRIMARY_DATABASE_URL` | not needed | **Required** (not needed if using the Railway template, see below) |
+| `CACHE_DATABASE_URL` | not needed | **Required** (not needed if using the Railway template, see below) |
+| `QUEUE_DATABASE_URL` | not needed | **Required** (not needed if using the Railway template, see below) |
+| `CABLE_DATABASE_URL` | not needed | **Required** (not needed if using the Railway template, see below) |
+| `SOLID_QUEUE_IN_PUMA` | auto-on | **Required** |
+| `APP_HOST` | not needed | **Required** |
+| `POSTMARK_API_TOKEN` | not needed | **Required** |
+| `MAIL_PROVIDER` | not needed | optional (`postmark` default, or `brevo`) |
+| `BREVO_API_KEY` | not needed | required only if `MAIL_PROVIDER=brevo` |
+| `MAIL_FROM_ADDRESS` | not needed | optional, defaults to `noreply@rockymtnruby.dev` |
+| `MISSION_CONTROL_USER` / `MISSION_CONTROL_PASSWORD` | optional | **Required** |
+| `TITO_API_TOKEN` / `TITO_ACCOUNT_SLUG` / `TITO_EVENT_SLUG` | optional | **Required** |
+| `RAILS_LOG_LEVEL`, `WEB_CONCURRENCY` | optional | optional |
+
+### Switching email providers
+
+`MAIL_PROVIDER` selects Postmark (`postmark`, the default when unset) or Brevo (`brevo`) —
+either can be primary or backup, there's nothing structurally special about either one. Set
+`MAIL_PROVIDER` and the matching provider's API key/token on Railway and redeploy; no code
+change needed either direction.
+
+The sender address (`MAIL_FROM_ADDRESS`, defaults to the one hardcoded in the app) is
+independent of which provider is active. Whichever address you use must be verified with
+whichever provider is currently selected — an unverified sender gets rejected by that
+provider's API.
+
+## Production / Railway deploy
+
+This app already runs on Railway upstream. `bin/docker-entrypoint` runs `bin/rails db:prepare`
+on every boot and bridges Railway's `$PORT` to Thruster's `$HTTP_PORT`. The `Dockerfile` is
+auto-detected, no `railway.toml` or Nixpacks config needed.
+
+The [Rails 8 Railway template](https://railway.com/deploy/railwayrails8starter) provisions four
+separate Postgres services, one each for primary, cache, queue, and cable, and wires the four
+`*_DATABASE_URL` variables to them automatically via Railway variable references. If you deploy
+from that template, those four variables are already set for you and don't need any manual
+configuration. If you provision your own single Postgres instead, point all four at it: each
+connection has its own `migrations_paths`, so they coexist fine in one database.
+
+1. Create a Railway project (from the template above, or your own).
+2. Set the remaining required variables above that the template doesn't already provide.
+3. Deploy.
+4. Verify, in order, each step gating the next:
+ 1. Deploy succeeds; logs show `db:prepare` completing for all four databases.
+ 2. The `/up` healthcheck passes.
+ 3. Boot logs show SolidQueue starting its worker/supervisor.
+ 4. `/admin/jobs` prompts for HTTP basic auth rather than opening.
+ 5. A magic-link login works end to end, with the email actually arriving. This single test
+ proves `SECRET_KEY_BASE`, `SOLID_QUEUE_IN_PUMA`, `APP_HOST`, and the Postmark token all
+ at once. It's the test that actually matters.
+
+Notes:
+- Collaborators require a Railway Pro plan. Trial and Hobby plans cannot invite team
+ members to a project. That's separate from adding admins to this app, which is just a row
+ in `db/seeds.rb`.
+- Postmark's free tier is 100 emails/month, which won't cover a full attendee list each needing
+ a login link. Budget for that, or switch providers (a small change to
+ `config/environments/production.rb`). Whichever provider you use, the sending domain needs
+ SPF/DKIM verified or every send is rejected.
+
+## Architecture notes
+
+- Four logical databases (primary, cache, queue, cable) can share one Postgres instance, see
+ above.
+- `db/seeds/schedule.rb` can be run standalone to re-sync the schedule without re-running the
+ admin-user seeds: `bin/rails runner db/seeds/schedule.rb`. Schedule content lives in
+ `config/schedule.yml`.
+- Never change an `external_id` in `db/seeds/embassy_questions.rb`, submitted applications
+ reference them.
+- `obsolete_slugs` in `db/seeds/schedule.rb` exists to clean up schedule items from
+ pre-rebrand databases; leave it alone.
diff --git a/app/assets/images/logo.svg b/app/assets/images/logo.svg
index 702eb89..71c6663 100644
--- a/app/assets/images/logo.svg
+++ b/app/assets/images/logo.svg
@@ -1 +1,13 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index ae6c6e3..ce9abd7 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -1,5 +1,5 @@
/* ============================================================
- * Ruby Embassy — plain CSS that mirrors the blueridgeruby.com
+ * Ruby Embassy — plain CSS that mirrors the rockymtnruby.dev
* styling. The HTML uses the same Tailwind-style class names
* the real site uses, so the markup is copy-paste compatible.
* Only the specific utilities used on that page are defined.
@@ -99,6 +99,7 @@ code {
.mx-auto { margin-left: auto; margin-right: auto; }
.mr-0\.5 { margin-right: 0.125rem; }
+.mr-2 { margin-right: 0.5rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mb-4 { margin-bottom: 1rem; }
@@ -197,12 +198,8 @@ code {
/* ---------- Page backgrounds (from the real site) --------- */
-.bg-ridge {
+.bg-boulder {
background-color: #ffffff;
- background-image: url("/images/ridge.jpg");
- background-size: 100% auto;
- background-repeat: no-repeat;
- background-position: 50% 100%;
}
.bg-mountains {
@@ -448,7 +445,7 @@ hr {
}
-/* ---------- Schedule day sections (blueridgeruby.com style) -- */
+/* ---------- Schedule day sections (rockymtnruby.dev style) -- */
.schedule-day {
margin-bottom: 5rem;
diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb
new file mode 100644
index 0000000..924d4e4
--- /dev/null
+++ b/app/controllers/admin/announcements_controller.rb
@@ -0,0 +1,49 @@
+class Admin::AnnouncementsController < AdminController
+ before_action :set_announcement, only: %i[edit update destroy]
+
+ def index
+ @announcements = Announcement.visible
+ end
+
+ def new
+ @announcement = Announcement.new
+ end
+
+ def create
+ @announcement = Announcement.new(announcement_params)
+ if @announcement.save
+ redirect_to admin_announcements_path,
+ notice: "Announcement posted."
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @announcement.update(announcement_params)
+ redirect_to admin_announcements_path,
+ notice: "Announcement updated."
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ @announcement.destroy!
+ redirect_to admin_announcements_path,
+ notice: "Announcement removed."
+ end
+
+ private
+
+ def set_announcement
+ @announcement = Announcement.find(params[:id])
+ end
+
+ def announcement_params
+ params.require(:announcement).permit(:title, :body, :pinned)
+ end
+end
diff --git a/app/controllers/admin/schedule_items_controller.rb b/app/controllers/admin/schedule_items_controller.rb
index 9281092..b5078f8 100644
--- a/app/controllers/admin/schedule_items_controller.rb
+++ b/app/controllers/admin/schedule_items_controller.rb
@@ -34,7 +34,7 @@ def index
def new
requested_kind = ScheduleItem.kinds.key?(params[:kind].to_s) ? params[:kind] : :talk
- @schedule_item = ScheduleItem.new(day: "fri", kind: requested_kind, is_public: true, flexible: false)
+ @schedule_item = ScheduleItem.new(day: "mon", kind: requested_kind, is_public: true, flexible: false)
end
def create
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 83bca54..802983a 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -9,6 +9,7 @@ class UsersController < AdminController
COMPUTED_SORTS = %w[rsvps volunteer_spots hosting].freeze
def index
+ @sync_status = TitoSyncJob.status
order_clause = apply_sort(SORTABLE_COLUMNS) || DEFAULT_ORDER
@query = params[:q].to_s.strip
@users = filtered_users.reorder(Arel.sql(order_clause)).to_a
@@ -69,6 +70,10 @@ def new
def create
@user = User.new(user_params)
+ # Locks the role against TitoSyncJob's auto-promotion. An admin
+ # explicitly choosing a role here — even the default — is a
+ # deliberate decision the sync should never override.
+ @user.role_set_by_admin = true if @user.role_changed?
if @user.save(context: :interactive)
redirect_to admin_users_path, notice: "User added."
@@ -84,6 +89,9 @@ def edit
def update
@user = User.find(params[:id])
@user.assign_attributes(user_params)
+ # See the same line in #create — an admin changing role here locks it
+ # against TitoSyncJob's auto-promotion.
+ @user.role_set_by_admin = true if @user.role_changed?
if @user.save(context: :interactive)
redirect_to admin_users_path, notice: "User updated."
@@ -98,42 +106,13 @@ def destroy
end
def sync
- users = User.all.to_a
- slugs = users.each_with_object({}) { |u, h| h[u.tito_ticket_slug] = u if u.tito_ticket_slug.present? }
- emails = users.each_with_object({}) { |u, h| (h[u.email.downcase] ||= u) if u.email.present? && u.tito_ticket_slug.blank? }
-
- already = 0
- connected = 0
- added = 0
-
- User.tito_client.tickets.where(state: %w[complete]).each do |ticket|
- if slugs[ticket.slug]
- already += 1
- elsif (user = emails[ticket.email.to_s.downcase])
- user.update!(
- tito_ticket_slug: ticket.slug,
- first_name: ticket.first_name,
- last_name: ticket.last_name
- )
- connected += 1
- else
- User.create!(
- tito_ticket_slug: ticket.slug,
- first_name: ticket.first_name,
- last_name: ticket.last_name,
- email: ticket.email,
- role: :attendee
- )
- added += 1
- end
+ if TitoSyncJob.running?
+ return redirect_to admin_users_path, alert: "A sync is already running."
end
- redirect_to admin_users_path,
- notice: "Sync complete: #{already} already linked, #{connected} connected, #{added} added."
- rescue StandardError => e
- Rails.logger.error("Tito sync error: #{e.class}: #{e.message}")
- redirect_to admin_users_path,
- alert: "Sync failed: #{e.message}. Check your Tito configuration."
+ TitoSyncJob.mark_running!
+ TitoSyncJob.perform_later
+ redirect_to admin_users_path, notice: "Sync started."
end
private
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 02e0cfd..60d781b 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -2,6 +2,7 @@ class DashboardController < ApplicationController
before_action :authenticate_user!
def show
+ @announcements = Announcement.visible
if current_user.volunteer? || current_user.admin?
@empty_volunteer_count = ScheduleItem.volunteer_empty.count
end
diff --git a/app/controllers/schedule_controller.rb b/app/controllers/schedule_controller.rb
index 14475e5..96690fe 100644
--- a/app/controllers/schedule_controller.rb
+++ b/app/controllers/schedule_controller.rb
@@ -1,5 +1,6 @@
class ScheduleController < ApplicationController
def index
+ @announcements = Announcement.visible
@selected_kind = ScheduleItem.kinds.key?(params[:kind].to_s) ? params[:kind] : nil
@show_unplanned = params[:unplanned].present?
@show_past = params[:show_past].present?
diff --git a/app/controllers/schedule_items_controller.rb b/app/controllers/schedule_items_controller.rb
index c4ee273..d622a55 100644
--- a/app/controllers/schedule_items_controller.rb
+++ b/app/controllers/schedule_items_controller.rb
@@ -3,7 +3,7 @@ class ScheduleItemsController < ApplicationController
def new
@schedule_item = current_user.created_schedule_items.build(
- day: params[:day] || "sat",
+ day: params[:day] || "mon",
flexible: false,
is_public: ActiveModel::Type::Boolean.new.cast(params.dig(:schedule_item, :is_public))
)
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index aac4486..d23aa20 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -1,7 +1,7 @@
class SessionsController < ApplicationController
skip_before_action :authenticate_user!, only: %i[new create callback]
- REGISTER_URL = "https://blueridgeruby.com/#register".freeze
+ REGISTER_URL = "https://rockymtnruby.dev/tickets/".freeze
def new
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index bcb1ff4..7a8ad12 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -37,8 +37,8 @@ def location_or_map_link(item)
end
end
- # Renders a talk's host as a link to their blueridgeruby.com speaker page
- # when host_url is set, otherwise as plain text. Returns nil if no host.
+ # Renders a talk's host as a link to their speaker page (host_url) when
+ # set, otherwise as plain text. Returns nil if no host.
def speaker_link(item)
return nil if item.host.blank?
if item.host_url.present?
diff --git a/app/helpers/embassy_questions_helper.rb b/app/helpers/embassy_questions_helper.rb
index 08a6574..bab728c 100644
--- a/app/helpers/embassy_questions_helper.rb
+++ b/app/helpers/embassy_questions_helper.rb
@@ -19,8 +19,8 @@ module EmbassyQuestionsHelper
].freeze
EMBASSY_HOURS = [
- "Thursday, April 30 · 9:00 AM – 4:30 PM",
- "Friday, May 1 · 9:00 AM – 5:00 PM"
+ "Monday, September 28 · 9:00 AM – 4:30 PM",
+ "Tuesday, September 29 · 9:00 AM – 5:00 PM"
].freeze
SECTION_META = {
diff --git a/app/jobs/tito_sync_job.rb b/app/jobs/tito_sync_job.rb
new file mode 100644
index 0000000..6bce8f0
--- /dev/null
+++ b/app/jobs/tito_sync_job.rb
@@ -0,0 +1,113 @@
+class TitoSyncJob < ApplicationJob
+ CACHE_KEY = "tito_sync:status"
+ STALE_AFTER = 15.minutes
+
+ # Maps a Tito release title to the role a ticket-holder should get.
+ # Matched by title, not release_id — release ids are recreated every year,
+ # but a "Volunteer"-ish title is likely to persist. Falls back to :attendee
+ # for anything unmatched, so a brand-new release type next year is safe by
+ # default rather than erroring.
+ ROLE_BY_RELEASE_TITLE = {
+ /volunteer/i => :volunteer
+ }.freeze
+
+ retry_on Tito::Error, wait: 30.seconds, attempts: 3 if defined?(Tito::Error)
+
+ def self.status
+ Rails.cache.read(CACHE_KEY)
+ end
+
+ def self.running?
+ status = self.status
+ status && status[:state] == :running && status[:started_at] > STALE_AFTER.ago
+ end
+
+ def self.mark_running!
+ Rails.cache.write(CACHE_KEY, { state: :running, started_at: Time.current })
+ end
+
+ def perform
+ users = User.all.to_a
+ slugs = users.each_with_object({}) { |u, h| h[u.tito_ticket_slug] = u if u.tito_ticket_slug.present? }
+ emails = users.each_with_object({}) { |u, h| (h[u.email.downcase] ||= u) if u.email.present? && u.tito_ticket_slug.blank? }
+ release_titles = fetch_release_titles
+
+ already = 0
+ connected = 0
+ added = 0
+ failed = 0
+
+ User.tito_client.tickets.where(state: %w[complete]).each do |ticket|
+ role = role_for(release_titles[ticket.release_id])
+
+ if (user = slugs[ticket.slug])
+ # This has to run here too, not just on connect/create below — most
+ # tickets hit this branch on every resync after the first, since
+ # they're already linked by slug.
+ promote!(user, role)
+ already += 1
+ elsif (user = emails[ticket.email.to_s.downcase])
+ user.update!(
+ tito_ticket_slug: ticket.slug,
+ first_name: ticket.first_name,
+ last_name: ticket.last_name
+ )
+ promote!(user, role)
+ connected += 1
+ else
+ User.create!(
+ tito_ticket_slug: ticket.slug,
+ first_name: ticket.first_name,
+ last_name: ticket.last_name,
+ email: ticket.email,
+ role: role
+ )
+ added += 1
+ end
+ rescue StandardError => e
+ # One malformed ticket shouldn't abort the whole run — count it and move on.
+ Rails.logger.error("Tito sync: skipped ticket #{ticket&.slug.inspect}: #{e.class}: #{e.message}")
+ failed += 1
+ end
+
+ write_status(state: :finished, already: already, connected: connected, added: added, failed: failed)
+ rescue StandardError => e
+ Rails.logger.error("Tito sync error: #{e.class}: #{e.message}")
+ write_status(state: :failed, error: e.message)
+ end
+
+ private
+
+ def fetch_release_titles
+ User.tito_client.releases.to_a.each_with_object({}) { |r, h| h[r.id] = r.title }
+ end
+
+ def role_for(release_title)
+ _, role = ROLE_BY_RELEASE_TITLE.find { |pattern, _| release_title&.match?(pattern) }
+ role || :attendee
+ end
+
+ # Promotes only — never demotes — and never touches a user whose role an
+ # admin has explicitly set, even if that's "attendee". Sync re-walks every
+ # ticket on every run with no since-last-sync filter, so this check runs
+ # on every single sync, not just the first time a ticket is seen.
+ def promote!(user, role)
+ return unless user.attendee? && role != :attendee && !user.role_set_by_admin?
+ user.update!(role: role)
+ end
+
+ def write_status(**attrs)
+ status = (Rails.cache.read(CACHE_KEY) || {}).merge(finished_at: Time.current, **attrs)
+ Rails.cache.write(CACHE_KEY, status)
+ broadcast_status(status)
+ end
+
+ def broadcast_status(status)
+ Turbo::StreamsChannel.broadcast_replace_to(
+ "tito_sync",
+ target: "sync_status",
+ partial: "admin/users/sync_status",
+ locals: { status: status }
+ )
+ end
+end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index 790f33a..3378bf5 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
- default from: "hello@blueridgeruby.com"
+ default from: ENV.fetch("MAIL_FROM_ADDRESS", "noreply@rockymtnruby.dev")
layout "mailer"
end
diff --git a/app/models/announcement.rb b/app/models/announcement.rb
new file mode 100644
index 0000000..40121d9
--- /dev/null
+++ b/app/models/announcement.rb
@@ -0,0 +1,6 @@
+class Announcement < ApplicationRecord
+ validates :title, presence: true
+ validates :body, presence: true
+
+ scope :visible, -> { order(pinned: :desc, created_at: :desc) }
+end
diff --git a/app/models/schedule_item.rb b/app/models/schedule_item.rb
index 486dba1..466d961 100644
--- a/app/models/schedule_item.rb
+++ b/app/models/schedule_item.rb
@@ -1,10 +1,11 @@
class ScheduleItem < ApplicationRecord
EMBASSY_MODES = %w[new_passport stamping passport_pickup].freeze
- HACK_DAY_SLUG = "sat-hackday"
+ HACK_DAY_SLUG = "tue-hackday"
DEFAULT_PLAN_KINDS = %w[talk reception].freeze
# One-off slugs that don't fit a default-plan kind but are still part of
- # the main programming every attendee is auto-RSVPed to.
- DEFAULT_PLAN_SLUGS = %w[thu-mystery].freeze
+ # the main programming every attendee is auto-RSVPed to. None for RMR
+ # currently — the BRR fork used this for a one-off "Mystery Activity".
+ DEFAULT_PLAN_SLUGS = [].freeze
belongs_to :created_by, class_name: "User", optional: true
has_many :plan_items, dependent: :destroy
@@ -34,19 +35,15 @@ class ScheduleItem < ApplicationRecord
validate :capacity_present_for_offered_modes, if: :embassy?
DAY_META = {
- "wed" => { label: "Wednesday", date: "April 29", subtitle: "Pre-Conference" },
- "thu" => { label: "Thursday", date: "April 30", subtitle: "Conference Day 1" },
- "fri" => { label: "Friday", date: "May 1", subtitle: "Conference Day 2" },
- "sat" => { label: "Saturday", date: "May 2", subtitle: "Activities & Ruby Embassy" },
- "sun" => { label: "Sunday", date: "May 3", subtitle: "Departures" }
+ "sun" => { label: "Sunday", date: "September 27", subtitle: "Pre-Conference" },
+ "mon" => { label: "Monday", date: "September 28", subtitle: "Conference Day 1" },
+ "tue" => { label: "Tuesday", date: "September 29", subtitle: "Conference Day 2" }
}.freeze
CONFERENCE_DATES = {
- "wed" => Date.new(2026, 4, 29),
- "thu" => Date.new(2026, 4, 30),
- "fri" => Date.new(2026, 5, 1),
- "sat" => Date.new(2026, 5, 2),
- "sun" => Date.new(2026, 5, 3)
+ "sun" => Date.new(2026, 9, 27),
+ "mon" => Date.new(2026, 9, 28),
+ "tue" => Date.new(2026, 9, 29)
}.freeze
private_constant :CONFERENCE_DATES
@@ -66,12 +63,10 @@ def self.upcoming_day_keys(today = Date.current)
order(
Arel.sql(
"CASE day " \
- "WHEN 'wed' THEN 1 " \
- "WHEN 'thu' THEN 2 " \
- "WHEN 'fri' THEN 3 " \
- "WHEN 'sat' THEN 4 " \
- "WHEN 'sun' THEN 5 " \
- "ELSE 6 END"
+ "WHEN 'sun' THEN 1 " \
+ "WHEN 'mon' THEN 2 " \
+ "WHEN 'tue' THEN 3 " \
+ "ELSE 4 END"
),
:sort_time
)
diff --git a/app/models/user.rb b/app/models/user.rb
index d3c1aec..4fca8e4 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -57,7 +57,7 @@ def tito_client = self.class.tito_client
def admin_ticket_url
return nil if tito_ticket_slug.blank?
- "https://dashboard.tito.io/#{tito_account_slug}/#{tito_event_slug}/tickets/#{tito_ticket_slug}"
+ "https://ti.to/#{tito_account_slug}/#{tito_event_slug}/tickets/#{tito_ticket_slug}"
end
def full_name
diff --git a/app/services/passport_application_pdf.rb b/app/services/passport_application_pdf.rb
index 2704511..f512684 100644
--- a/app/services/passport_application_pdf.rb
+++ b/app/services/passport_application_pdf.rb
@@ -20,7 +20,7 @@
# For blank-batch printing, pass `application: nil, count: N` to render
# N applications back-to-back in one document.
class PassportApplicationPdf
- FORM_CODE = "Form RE-1 (Rev. 2026-04)".freeze
+ FORM_CODE = "Form RE-1 (Rev. 2026-09)".freeze
PAGE_SIZE = "LETTER".freeze
MARGIN = 36
LONG_LINE_HEIGHT = 9.5
@@ -355,7 +355,7 @@ def render_header(pdf, application, title: "RUBY EMBASSY · APPLICATION FOR PASS
pdf.font_size 8
pdf.text FORM_CODE, align: :right, style: :bold, size: 7
pdf.text title, size: 11, style: :bold, align: :center
- pdf.text "Blue Ridge Ruby Embassy · Asheville, NC", size: 7.5, align: :center, style: :italic
+ pdf.text "Rocky Mountain Ruby Embassy · Boulder, CO", size: 7.5, align: :center, style: :italic
pdf.move_down 3
pdf.stroke_horizontal_rule
pdf.move_down 3
@@ -406,16 +406,16 @@ def metadata_row(pdf, pairs)
[
"EMBASSY ORDINANCES (EXCERPTED)",
[
- "§1. Validity. This application shall remain valid for the duration of Blue Ridge Ruby 2026 and may not be transferred to any subsequent calendar year, conference, or commemorative gathering. Validity does not survive the expiration of the lead maintainer's patience.",
+ "§1. Validity. This application shall remain valid for the duration of Rocky Mountain Ruby 2026 and may not be transferred to any subsequent calendar year, conference, or commemorative gathering. Validity does not survive the expiration of the lead maintainer's patience.",
"§2. Discretion. The Embassy reserves sole and absolute discretion to deny issuance for cause, including but not limited to: insufficient ceremony, ill-fitting suspenders, or a documented hatred of the Ruby programming language.",
"§3. Truthfulness. Falsified declarations may result in revocation of Ruby Embassy privileges for up to three (3) event days and forfeiture of any commemorative stamps so obtained. Repeat offenders may be required to write a sincere apology in YAML.",
"§4. Notary Conduct. The Applicant shall conduct themselves with reasonable courtesy toward the Notary. Bribery of the Notary is strictly prohibited unless said bribery consists of coffee, in which case discretion is advised.",
- "§5. Right of Appeal. Applicants whose stamping is denied may request review by writing to noreply@blueridgeruby.com. Review proceedings, where granted, are conducted ex parte and concluded summarily.",
+ "§5. Right of Appeal. Applicants whose stamping is denied may request review by writing to noreply@rockymtnruby.dev. Review proceedings, where granted, are conducted ex parte and concluded summarily.",
"§6. Liability. The Embassy assumes no liability for stamping-related psychological distress, including but not limited to: imposter syndrome, premature optimization, or the realization that one has been pronouncing \"RubyGems\" wrong this entire time.",
"§7. Decorum. The Applicant shall maintain decorum throughout proceedings, defined for purposes of this ordinance as: not laughing audibly during the stamping, not narrating the Notary's signature in real time, and not attempting to high-five the Attaché unless reciprocity is clearly indicated.",
"§8. Documentation. The Applicant must retain a copy of the stamped Passport for a period of not less than seven (7) calendar days, after which the document may be displayed on the Applicant's mantel, refrigerator, or other location of comparable ceremony.",
"§9. Reciprocal Recognition. The Embassy shall, upon request and at the Attaché's discretion, recognize Passports issued by sister Ruby Embassies hosted at other regional and international Ruby gatherings, subject to verification of the issuing event's standing.",
- "§10. Jurisdiction. Disputes arising under these ordinances shall be adjudicated within the geographic boundaries of Asheville, North Carolina, or wherever good coffee can be reasonably procured, whichever is more convenient.",
+ "§10. Jurisdiction. Disputes arising under these ordinances shall be adjudicated within the geographic boundaries of Boulder, Colorado, or wherever good coffee can be reasonably procured, whichever is more convenient.",
"§11. Force Majeure. The Embassy shall not be held liable for failures caused by acts of God, acts of CDN, deprecated dependencies, expired SSL certificates, or the abrupt unavailability of the lead maintainer.",
"§12. Amendments. These ordinances may be amended at any time by the Embassy Attaché, with or without notice, retroactively if necessary, and the Applicant hereby acknowledges this fact in advance and without further objection.",
"§13. Counterparts. This document may be executed in counterparts, each of which shall be deemed an original, even when neither is, in fact, an original.",
@@ -434,7 +434,7 @@ def metadata_row(pdf, pairs)
"\"Applicant\" means the natural person identified in Section 1 of this Application, including any pseudonyms, callsigns, GitHub handles, or other documented aliases.",
"\"Attaché\" means the duly appointed representative of the Embassy on premises during the three (3) event days, identifiable by official Embassy lanyard and a faintly weary expression.",
"\"Business Gems\" means three (3) RubyGems published open source by Embassy personnel and listed on rubygems.org, irrespective of download count, semantic versioning practices, or whether such Gems remain actively maintained.",
- "\"Embassy\" means the Blue Ridge Ruby Embassy at Blue Ridge Ruby 2026, including all temporary structures, designated tables, hallway corners, and adjacent vibe zones.",
+ "\"Embassy\" means the Rocky Mountain Ruby Embassy at Rocky Mountain Ruby 2026, including all temporary structures, designated tables, hallway corners, and adjacent vibe zones.",
"\"Notary\" means any attendee meeting the criteria set forth in Box A.1 of the Addendum, voluntarily acting in such capacity for purposes of this Application only.",
"\"Passport\" means the formal document issued by the Embassy bearing one or more stamps of recognition, accompanied by such ceremony as the Attaché deems appropriate.",
"\"Stamping\" means the act of applying said stamp to said Passport, performed by the Attaché in the presence of the Applicant and any onlookers who happen to be present.",
diff --git a/app/views/admin/announcements/_form.html.erb b/app/views/admin/announcements/_form.html.erb
new file mode 100644
index 0000000..266bb08
--- /dev/null
+++ b/app/views/admin/announcements/_form.html.erb
@@ -0,0 +1,38 @@
+<%# locals: (announcement:) -%>
+<% if announcement.errors.any? %>
+
+
+ <% announcement.errors.full_messages.each do |msg| %>
+ <%= msg %>
+ <% end %>
+
+
+<% end %>
+
+
+ <%= form_with model: announcement,
+ url: (announcement.persisted? ? admin_announcement_path(announcement) : admin_announcements_path),
+ local: true,
+ class: "space-y-4" do |f| %>
+
+ <%= f.label :title, class: "label" %>
+ <%= f.text_field :title, class: "input", placeholder: "e.g., Venue wifi" %>
+
+
+
+ <%= f.label :body, class: "label" %>
+ <%= f.text_area :body, rows: 4, class: "input",
+ placeholder: "e.g., Network: RMR26 · Password: rubyrubyruby" %>
+
+
+
+ <%= f.check_box :pinned %>
+ <%= f.label :pinned, "Pin to top (for wifi and other must-see info)" %>
+
+
+
+ <%= f.submit "Save Announcement", class: "btn btn-navy" %>
+ <%= link_to "Cancel", admin_announcements_path, class: "btn btn-muted" %>
+
+ <% end %>
+
diff --git a/app/views/admin/announcements/edit.html.erb b/app/views/admin/announcements/edit.html.erb
new file mode 100644
index 0000000..d543f4d
--- /dev/null
+++ b/app/views/admin/announcements/edit.html.erb
@@ -0,0 +1,5 @@
+<% content_for :title, "Edit Announcement — Embassy Admin" %>
+
+Edit Announcement
+
+<%= render "form", announcement: @announcement %>
diff --git a/app/views/admin/announcements/index.html.erb b/app/views/admin/announcements/index.html.erb
new file mode 100644
index 0000000..cb81ba1
--- /dev/null
+++ b/app/views/admin/announcements/index.html.erb
@@ -0,0 +1,46 @@
+<% content_for :title, "Announcements — Embassy Admin" %>
+
+
+
+
Announcements
+
+ Shown to signed-in guests at the top of the dashboard and schedule.
+ Pin important ones (like wifi) to keep them on top.
+
+
+ <%= link_to "+ New Announcement", new_admin_announcement_path, class: "btn btn-red" %>
+
+
+<% if @announcements.any? %>
+
+<% else %>
+ No announcements yet.
+<% end %>
diff --git a/app/views/admin/announcements/new.html.erb b/app/views/admin/announcements/new.html.erb
new file mode 100644
index 0000000..7334b2b
--- /dev/null
+++ b/app/views/admin/announcements/new.html.erb
@@ -0,0 +1,5 @@
+<% content_for :title, "New Announcement — Embassy Admin" %>
+
+New Announcement
+
+<%= render "form", announcement: @announcement %>
diff --git a/app/views/admin/schedule_items/_form.html.erb b/app/views/admin/schedule_items/_form.html.erb
index e88e3a5..362a764 100644
--- a/app/views/admin/schedule_items/_form.html.erb
+++ b/app/views/admin/schedule_items/_form.html.erb
@@ -74,7 +74,7 @@
<%= f.label :host_url, "Speaker profile URL", class: "label" %>
<%= f.url_field :host_url, class: "input",
- placeholder: "https://blueridgeruby.com/speakers/..." %>
+ placeholder: "https://rockymtnruby.dev/#program" %>
Optional. If set, the speaker's name links to this URL on /schedule and /plan.
Update this whenever you change the host so the link points to the right person.
@@ -102,7 +102,7 @@
<%= f.check_box :flexible %>
- <%= f.label :flexible, "Flexible / TBD (Saturday dashed treatment)" %>
+ <%= f.label :flexible, "Flexible / TBD (dashed treatment)" %>
diff --git a/app/views/admin/users/_sync_status.html.erb b/app/views/admin/users/_sync_status.html.erb
new file mode 100644
index 0000000..f73746a
--- /dev/null
+++ b/app/views/admin/users/_sync_status.html.erb
@@ -0,0 +1,39 @@
+
+
+
Users
+
+ <%= button_to "↻ Sync from Tito", sync_admin_users_path, method: :post, class: "btn btn-navy",
+ disabled: status && status[:state] == :running,
+ data: { turbo_submits_with: "Starting…" } %>
+ <%= link_to "Add user", new_admin_user_path, class: "btn btn-red" %>
+
+
+
+ <% if status.present? %>
+ <% case status[:state]
+ when :running %>
+
+ ⏺ Sync running, started <%= time_ago_in_words(status[:started_at]) %> ago…
+
+
+ <% when :finished %>
+
+ ✓ Last sync <%= time_ago_in_words(status[:finished_at]) %> ago:
+ <%= status[:already] %> already linked, <%= status[:connected] %> connected,
+ <%= status[:added] %> added
+ <% if status[:failed].to_i > 0 %>
+ · <%= status[:failed] %> ticket<%= "s" if status[:failed] != 1 %> skipped (see logs)
+ <% end %>
+
+
+ <% when :failed %>
+
+ ✗ Sync failed <%= time_ago_in_words(status[:finished_at]) %> ago:
+ <%= status[:error] %>. Check your Tito configuration.
+
+
+ <% end %>
+ <% end %>
+
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb
index cc6d3f8..eb60860 100644
--- a/app/views/admin/users/index.html.erb
+++ b/app/views/admin/users/index.html.erb
@@ -1,12 +1,8 @@
<% content_for :container_width, "max-w-7xl" %>
-
-
Users
-
- <%= button_to "Sync from Tito", sync_admin_users_path, method: :post, class: "btn btn-navy" %>
- <%= link_to "Add user", new_admin_user_path, class: "btn btn-red" %>
-
-
+<%= turbo_stream_from "tito_sync" %>
+
+<%= render "sync_status", status: @sync_status %>
<%= form_with url: admin_users_path, method: :get, local: true, class: "mb-6 flex gap-2 flex-wrap" do %>
<%= text_field_tag :q, @query,
diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb
index 62197b6..aabaa1a 100644
--- a/app/views/dashboard/show.html.erb
+++ b/app/views/dashboard/show.html.erb
@@ -13,6 +13,8 @@
What would you like to do?
+ <%= render "shared/announcements", announcements: @announcements %>
+
<% if (current_user.volunteer? || current_user.admin?) && @empty_volunteer_count.to_i > 0 %>
<%= link_to schedule_path(kind: :volunteer), class: "action-card action-card--hero mb-6" do %>
Volunteers Needed
@@ -27,7 +29,7 @@
<%= link_to schedule_path, class: "action-card" do %>
See the schedule
-
Browse all four days of talks, activities, and Ruby Embassy blocks.
+
Browse all three days of talks, activities, and Ruby Embassy blocks.
→
<% end %>
@@ -49,14 +51,16 @@
→
<% end %>
+ <%#
Get your Ashevillager
Make your Ashevillager and join the locals you might run into around town.
↗
+ %>
-
- Explore Asheville
+
+ Explore Boulder
Local picks for food, coffee, hikes, and things to do off-schedule.
↗
diff --git a/app/views/embassy_applications/new.html.erb b/app/views/embassy_applications/new.html.erb
index 4000777..e934a73 100644
--- a/app/views/embassy_applications/new.html.erb
+++ b/app/views/embassy_applications/new.html.erb
@@ -8,7 +8,7 @@