This file provides guidance to AI coding agents working with this repository.
WhyRuby.info is a Ruby advocacy community website built with Ruby 4.0.1 and Rails 8.2 (edge/main branch) using the Solid Stack (SQLite, SolidQueue, SolidCache, SolidCable). It features a universal content model supporting articles, links, and success stories with AI-generated summaries, GitHub OAuth authentication, community-driven content moderation, user testimonials, an interactive developer map, GitHub project tracking with star trends, and a timezone-aware newsletter system.
The app runs on two domains:
- whyruby.info — advocacy content and testimonials
- rubycommunity.org — community profiles, map, and project rankings
# Start the development server (REQUIRED - do not use rails server directly)
bin/dev
# Monitor logs in real-time
tail -f log/development.logIMPORTANT: Always use bin/dev instead of rails server. This starts both the Rails server (port 3003) and Tailwind CSS watcher via Procfile.dev.
# Create and setup database
rails db:create
rails db:migrate
rails db:seed
# Database console
rails db
# Promote user to admin
rails runner "User.find_by(username: 'github-username').update!(role: :admin)"# Run all tests
rails test
# Run single test file
rails test test/models/post_test.rb
# Run single test
rails test test/models/post_test.rb:line_number
# Code style (auto-fixes and stages changes via lefthook)
bundle exec rubocop
bundle exec rubocop -A # Auto-correct issues# Console
rails console
# Generate model with UUIDv7 primary key
rails generate model ModelName
# Other generators
rails generate controller ControllerName
rails generate migration MigrationNameThe codebase follows 37signals vanilla Rails style: fat models with concerns, thin controllers, no service objects. All domain logic lives in models and model concerns. There is no app/services/ directory.
The application uses a Universal Content Model where the Post model handles three distinct content types via the post_type enum:
- Articles: Original content with markdown (
contentfield required,urlmust be blank) - Links: External content (
urlrequired,contentmust be blank) - Success Stories: Ruby success stories with SVG logo (
logo_svgandcontentrequired,urlmust be blank)
Key Models:
User: GitHub OAuth authentication, role-based access (member/admin), trusted user system based on contribution counts (3+ published posts, 10+ published comments), geocoded location, timezone, newsletter tracking, profile settings (hide_repositories, open_to_work)Post: Universal content model with FriendlyId slugs, soft deletion viaarchivedflag, counter caches for reportsCategory: Content organization with position ordering, special success story category flagTag: HABTM relationship with postsComment: User feedback on posts with published flagReport: Content moderation by trusted users, auto-hides after 3+ reportsTestimonial: User testimonials ("why I love Ruby") with AI-generated headline, subheadline, and quote. Validated by LLM for appropriatenessProject: GitHub repositories for users with star counts, language, descriptionStarSnapshot: Daily star count snapshots per project, used to compute trending/stars gainedChat: RubyLLM chat records (acts_as_chat). Has apurposecolumn:"conversation"(default),"summary","testimonial_generation","testimonial_validation". System chats (non-conversation) track AI operations for cost accountingModel: RubyLLM model records (acts_as_model). Tracks available AI models and their cumulative costs
All domain logic that was previously in service objects now lives in model concerns, following the 37signals naming convention (adjective-named, namespaced to the model they belong to).
User concerns (app/models/concerns/user/):
User::Geocodable— geocodes free-text locations into structured data (city, country, coordinates) via Photon API, resolves timezone from coordinatesUser::GithubSyncable— syncs GitHub profile data and repositories via GraphQL API on sign-in, supports batch fetching for bulk updates
Post concerns (app/models/concerns/post/):
Post::SvgSanitizable— sanitizes SVG content to prevent XSS attacks in success story logosPost::MetadataFetchable— fetches OpenGraph metadata and external content from URLs for link postsPost::ImageVariantable— processes featured images into WebP variants (small, medium, large, og) via ActiveStoragePost::OgImageGeneratable— generates OG images for success stories from SVG logosPost::AiSummarizable— generates AI summary teasers via RubyLLM, creates a system chat withpurpose: "summary"
Testimonial concerns (app/models/concerns/testimonial/):
Testimonial::AiGeneratable— AI-generates headline, subheadline, and body text from user testimonial quote via RubyLLMTestimonial::AiValidatable— validates testimonial content for appropriateness via RubyLLM
Shared concerns (app/models/concerns/):
Costable— cost formatting and calculation for models with atotal_costcolumn (used by User, Chat, Model)
All AI operations use the RubyLLM gem (~> 1.9), configured with default_model: "gpt-4.1-nano" in config/initializers/ruby_llm.rb.
How AI operations work:
- A concern method (e.g.,
Post#generate_summary!) creates a systemChatrecord with a specificpurpose - It calls
chat.ask(prompt)which uses RubyLLM to send the request and record the response as messages - Message costs are tracked automatically via RubyLLM's
acts_as_chat/acts_as_model - Per-user spending is available via the
Costableconcern onUser
Chat purposes:
"conversation"— default, for user-facing chats (not currently used in this app)"summary"— AI summary generation for posts"testimonial_generation"— AI headline/subheadline/body generation for testimonials"testimonial_validation"— AI content validation for testimonials
Key pattern: Jobs are thin delegators that call model methods. The model concern owns all the logic:
# Job (thin delegator)
class GenerateSummaryJob < ApplicationJob
def perform(post, force: false)
post.generate_summary!(force: force)
end
end
# Concern (owns the logic)
module Post::AiSummarizable
def generate_summary!(force: false)
chat = user.chats.create!(purpose: "summary", model: Model.find_by(...))
response = chat.ask(prompt)
update!(summary: clean_ai_summary(response.content))
end
endAll tables use UUIDv7 string primary keys via the uuid7() SQLite function:
create_table :table_name, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t|
# ...
end- Authentication: GitHub OAuth only via Devise + OmniAuth
- Roles:
member(default) andadmin(enum in User model) - Trusted Users: Automatically qualified when
published_posts_count >= 3ANDpublished_comments_count >= 10 - Permissions: Only trusted users can report content; only admins can access
/admin(Avo panel)
- Trusted users can report inappropriate content
- After 3+ reports, content is automatically:
- Set to
published: false - Flagged with
needs_admin_review: true - Admin notification sent via
NotifyAdminJob
- Set to
- Admins review and restore or permanently archive via Avo panel
All jobs are thin delegators that call model methods. The logic lives in model concerns, not in jobs.
GenerateSummaryJob: Callspost.generate_summary!(AI summary viaPost::AiSummarizable)GenerateSuccessStoryImageJob: Callspost.generate_og_image!(OG image viaPost::OgImageGeneratable)GenerateTestimonialJob: Callstestimonial.generate_ai_fields!(AI fields viaTestimonial::AiGeneratable)ValidateTestimonialJob: Callstestimonial.validate_with_ai!(AI validation viaTestimonial::AiValidatable)NotifyAdminJob: Sends notifications when content is auto-hiddenUpdateGithubDataJob: Batch-syncs GitHub data viaUser.batch_sync_github_data!(fromUser::GithubSyncable)NormalizeLocationJob: Callsuser.geocode!(geocoding viaUser::Geocodable)ScheduledNewsletterJob: Sends newsletter emails at timezone-appropriate times
Posts support featured_image attachments via ActiveStorage. Images are processed into multiple WebP variants (small, medium, large, og) and stored as separate blobs with metadata in image_variants JSON column. Processing is handled by the Post::ImageVariantable concern.
The application uses a catch-all routing strategy for clean URLs:
/:category_slug # Category pages
/:category_slug/:post_slug # Post pages
Important: Category and post routes use constraints and are defined at the END of routes.rb to avoid conflicts with explicit routes like /admin, /community, /legal/*, etc.
The app runs on two domains:
- whyruby.info — main content site
- rubycommunity.org — community/user profiles (production only)
Domain config lives in config/initializers/domains.rb. In development, community pages are served under /community on localhost. In production, they live at the root of rubycommunity.org (e.g. rubycommunity.org/username). Production routes redirect /community/* on the primary domain to rubycommunity.org/* with 301s.
When linking to user profiles, always use community_user_canonical_url(user) (or community_root_canonical_url for the index). These helpers resolve to the correct domain per environment. Never use user_path/user_url in user-facing links — those only produce the local /community/... paths.
Cross-domain authentication: OAuth goes through the primary domain. A cross-domain token system (AuthController) syncs sessions between domains. Tokens are single-use and expire in 30 seconds. The safe_return_to method validates redirect URLs against allowed domains.
Footer legal links: Use main_site_url(path) helper to ensure legal page links resolve to the primary domain when viewed on the community domain.
Both User and Post models use FriendlyId with history:
User: Slugs fromusernamePost: Slugs fromtitle
Both models implement create_slug_history to manually save old slugs when changed, ensuring historical URLs redirect properly.
- SolidQueue: Default background job processor (no Redis needed)
- SolidCache: Database-backed caching
- SolidCable: WebSocket functionality via SQLite
- Configuration files:
config/queue.yml,config/cache.yml,config/cable.yml
- Use
params.expect()for parameter handling instead of strong parameters (Rails 8.1 feature) - Propshaft for asset pipeline (not Sprockets)
- Tailwind CSS 4 via
tailwindcss-railsgem - Hotwire (Turbo + Stimulus) for frontend interactivity
Always use UUIDv7 string primary keys via the uuid7() SQLite function. Never use auto-increment integers:
create_table :posts, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t|
t.string :title, null: false
# ...
end- ALWAYS use
bin/devto start the server - Check logs after every significant change
- Monitor
log/development.logfor errors and performance issues - Review logs before considering any change complete
The project uses lefthook to run checks before commits:
- RuboCop: Runs with auto-fix (
-A) on all files, fixed files are automatically staged - Brakeman: Runs
bin/brakeman --no-pagerfor security scanning (must pass with zero warnings) - Configuration in
.lefthook.yml,.rubocop.yml, andconfig/brakeman.ignore
- Framework: Minitest (Rails default)
- Fixtures in
test/fixtures/ - Test structure:
test/models/,test/controllers/,test/system/ - Run tests before committing
rails credentials:edit --environment developmentRequired credentials:
github:
client_id: your_github_oauth_app_client_id
client_secret: your_github_oauth_app_client_secret
openai:
api_key: your_openai_api_key # Used by RubyLLM for AI operations- Create OAuth App at: https://github.com/settings/developers
- Set callback URL:
http://localhost:3000/users/auth/github/callback - Add credentials to development credentials file
The GeoLite2 database is used for IP geolocation (analytics country code). It's not redistributable, so it's gitignored and downloaded during Docker build.
Development setup:
- Create a free MaxMind account: https://www.maxmind.com/en/geolite2/signup
- Generate a license key: https://www.maxmind.com/en/accounts/current/license-key
- Download GeoLite2-Country database and place at:
db/GeoLite2-Country.mmdb
Production setup:
Add to production credentials (rails credentials:edit --environment production):
maxmind:
account_id: your_account_id # Find at top of MaxMind license key page
license_key: your_license_keyThe Dockerfile automatically downloads the database during build if both MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY are provided. Note: MaxMind API changed in May 2024 to require both credentials.
- Configured for Kamal 2 deployment (see
config/deploy.yml) - Litestream for SQLite backups to S3 (see
config/litestream.yml) - RorVsWild for performance monitoring (see
config/rorvswild.yml)
- Admin panel powered by Avo (v3.2+)
- Route:
/admin(only accessible to admin users) - Configuration:
app/avo/directory - Litestream backup UI:
/litestream(admin only)
- Redcarpet for markdown parsing
- Rouge for syntax highlighting
- Markdown content stored in
Post#contentfield for articles and success stories - Custom helper:
ApplicationHelper#render_markdown_with_syntax_highlighting