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? %> +
+ +
+<% 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? %> +
+ + + + + + + + + + + <% @announcements.each do |announcement| %> + + + + + + + <% end %> + +
TitleBodyPinned
<%= announcement.title %><%= truncate(announcement.body, length: 120) %><%= announcement.pinned? ? "📌" : "" %> +
+ <%= link_to "Edit", edit_admin_announcement_path(announcement), class: "btn btn-muted btn-small" %> + <%= button_to "Delete", admin_announcement_path(announcement), method: :delete, + data: { turbo_confirm: "Remove this announcement?" }, + class: "btn btn-red btn-small" %> +
+
+
+<% 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 @@
-
Form RUBY-1.0-MATZ · Rev. 04/2026
+
Form RUBY-1.0-MATZ · Rev. 09/2026

Ruby Embassy

Application for Issuance of New Ruby Passport

diff --git a/app/views/embassy_applications/show.html.erb b/app/views/embassy_applications/show.html.erb index 9dc398c..a4ec383 100644 --- a/app/views/embassy_applications/show.html.erb +++ b/app/views/embassy_applications/show.html.erb @@ -2,7 +2,7 @@
-

Form RUBY-1.0-MATZ · Rev. 04/2026

+

Form RUBY-1.0-MATZ · Rev. 09/2026

Application Received

The Applicant's appointment is confirmed. Please print and bring the attached application to the Embassy.

diff --git a/app/views/embassy_bookings/create.html.erb b/app/views/embassy_bookings/create.html.erb index b4538f8..178014b 100644 --- a/app/views/embassy_bookings/create.html.erb +++ b/app/views/embassy_bookings/create.html.erb @@ -2,7 +2,7 @@
-

Form RUBY-1.0-MATZ · Rev. 04/2026

+

Form RUBY-1.0-MATZ · Rev. 09/2026

Ruby Embassy

Stamping Appointment · Confirmed

diff --git a/app/views/embassy_bookings/new.html.erb b/app/views/embassy_bookings/new.html.erb index 0d596fa..ce495d7 100644 --- a/app/views/embassy_bookings/new.html.erb +++ b/app/views/embassy_bookings/new.html.erb @@ -2,7 +2,7 @@
-

Form RUBY-1.0-MATZ · Rev. 04/2026

+

Form RUBY-1.0-MATZ · Rev. 09/2026

Ruby Embassy

Application for Appointment · Booking Confirmation

diff --git a/app/views/errors/internal_server_error.html.erb b/app/views/errors/internal_server_error.html.erb index 7242702..39316a0 100644 --- a/app/views/errors/internal_server_error.html.erb +++ b/app/views/errors/internal_server_error.html.erb @@ -10,7 +10,7 @@ We hit an unexpected error. The team has been notified. Try the main site, or come back in a minute.

- + Back to main site <%= link_to "Back to dashboard", dashboard_path, class: "btn btn-muted" %> diff --git a/app/views/errors/not_found.html.erb b/app/views/errors/not_found.html.erb index 1744177..a04a429 100644 --- a/app/views/errors/not_found.html.erb +++ b/app/views/errors/not_found.html.erb @@ -11,7 +11,7 @@ It's not you — probably.

- + Back to main site <%= link_to "Back to dashboard", dashboard_path, class: "btn btn-muted" %> diff --git a/app/views/errors/unprocessable_content.html.erb b/app/views/errors/unprocessable_content.html.erb index d7eb6e3..65399b1 100644 --- a/app/views/errors/unprocessable_content.html.erb +++ b/app/views/errors/unprocessable_content.html.erb @@ -10,7 +10,7 @@ Your session may have expired, or the link you clicked is no longer valid. Try again from a fresh page.

- + Back to main site <%= link_to "Back to dashboard", dashboard_path, class: "btn btn-muted" %> diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index a9e8cd4..5ed6cd0 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -25,6 +25,7 @@ <%= link_to "Attendees", admin_users_path %> <%= link_to "Volunteers", admin_volunteers_path %> <%= link_to "Schedule", admin_schedule_items_path %> + <%= link_to "Announcements", admin_announcements_path %> <%= link_to "Lightning Talks", admin_lightning_talks_path %> <%= link_to "Hack Day", admin_hack_projects_path %> <%= link_to "Embassy Applications", admin_embassy_applications_path %> @@ -45,6 +46,7 @@ <%= link_to "Attendees", admin_users_path %> <%= link_to "Volunteers", admin_volunteers_path %> <%= link_to "Schedule", admin_schedule_items_path %> + <%= link_to "Announcements", admin_announcements_path %> <%= link_to "Lightning Talks", admin_lightning_talks_path %> <%= link_to "Hack Day", admin_hack_projects_path %> <%= link_to "Embassy Applications", admin_embassy_applications_path %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 3e5f383..dbe6813 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -26,7 +26,7 @@ -
+
<%= render "shared/navbar" %>
diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index ecdc4b9..9a450ed 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -10,7 +10,7 @@

- This page is a template demo. The navbar, footer, colors, typography, and layout match the main Blue Ridge Ruby website so the transition between sites is seamless. + This page is a template demo. The navbar, footer, colors, typography, and layout match the main Rocky Mountain Ruby website so the transition between sites is seamless.

diff --git a/app/views/report/index.html.erb b/app/views/report/index.html.erb index cdb9b5a..98746b7 100644 --- a/app/views/report/index.html.erb +++ b/app/views/report/index.html.erb @@ -287,7 +287,7 @@

⁂

- Filed in Asheville on <%= @report.generated_at.strftime("%B %-d, %Y") %>, on behalf of the Embassy. Subject to revision pending further submissions. + Filed in Boulder on <%= @report.generated_at.strftime("%B %-d, %Y") %>, on behalf of the Embassy. Subject to revision pending further submissions.

diff --git a/app/views/schedule/index.html.erb b/app/views/schedule/index.html.erb index cb50402..be1032f 100644 --- a/app/views/schedule/index.html.erb +++ b/app/views/schedule/index.html.erb @@ -10,6 +10,8 @@ Browse the full conference agenda. Tap Add to plan on any talk or RSVP on any activity to save it to your Plan.

+ <%= render "shared/announcements", announcements: @announcements %> + <%= render "shared/kind_filter", base_path: schedule_path, selected_kind: @selected_kind %>
diff --git a/app/views/sessions/not_registered.html.erb b/app/views/sessions/not_registered.html.erb index 5162053..a1b2f6b 100644 --- a/app/views/sessions/not_registered.html.erb +++ b/app/views/sessions/not_registered.html.erb @@ -7,17 +7,17 @@

- We couldn't find a Blue Ridge Ruby ticket for + We couldn't find a Rocky Mountain Ruby ticket for
<%= @email %>.

- You'll need a ticket before you can sign in. Register on the Blue Ridge Ruby site to get started. + You'll need a ticket before you can sign in. Register on the Rocky Mountain Ruby site to get started.

- Register for Blue Ridge Ruby + Register for Rocky Mountain Ruby

diff --git a/app/views/shared/_announcements.html.erb b/app/views/shared/_announcements.html.erb new file mode 100644 index 0000000..5203333 --- /dev/null +++ b/app/views/shared/_announcements.html.erb @@ -0,0 +1,16 @@ +<%# locals: (announcements:) -%> +<% if announcements.any? %> +

+ <% announcements.each do |announcement| %> +
+

+ <% if announcement.pinned? %> + + <% end %> + <%= announcement.title %> +

+
<%= simple_format(announcement.body) %>
+
+ <% end %> +
+<% end %> diff --git a/app/views/shared/_day_filter.html.erb b/app/views/shared/_day_filter.html.erb index b1116ee..8194755 100644 --- a/app/views/shared/_day_filter.html.erb +++ b/app/views/shared/_day_filter.html.erb @@ -1,7 +1,7 @@ <%# locals: base_path - String, e.g. admin_schedule_items_path - selected_day - String or nil, the currently active day key ("wed", "thu", etc.) + selected_day - String or nil, the currently active day key ("sun", "mon", etc.) %>