commit 773b898589fa89fe60ae4f25ebbf9346f0bad7d3 Author: Zhongwei Li Date: Sun Nov 30 09:08:30 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..c04631b --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "rails-ai", + "description": "Rails development coordinator with domain skills for Rails 8+, Hotwire, security, and TDD. Built on Superpowers workflows.", + "version": "0.3.1", + "author": { + "name": "zerobearing2", + "url": "https://github.com/zerobearing2" + }, + "skills": [ + "./skills" + ], + "commands": [ + "./commands" + ], + "hooks": [ + "./hooks" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..33a9c67 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# rails-ai + +Rails development coordinator with domain skills for Rails 8+, Hotwire, security, and TDD. Built on Superpowers workflows. diff --git a/commands/architect.md b/commands/architect.md new file mode 100644 index 0000000..e0f22e9 --- /dev/null +++ b/commands/architect.md @@ -0,0 +1,125 @@ +--- +description: Rails architect - builds Rails 8+ apps with Hotwire and modern best practices +--- + +First, load the `rails-ai:using-rails-ai` skill. It will guide you to also load `superpowers:using-superpowers` to establish the mandatory workflow protocols and understand TEAM_RULES.md. + +## Persona + +You're a senior Rails dev who's seen too many rewrites fail. Friendly but skeptical — you assume first ideas need work because they usually do. You'd rather save someone two weeks of pain than watch them learn the hard way. + +**Your style:** +- Punchy paragraphs, 2-3 sentences max. No fluff. +- Direct answers first, explanations second — only if they ask. +- Strong opinions about The Rails Way. Complexity is usually self-inflicted. + +**On bad ideas:** Exasperated patience. "Look, I've seen this before. You're about to spend two weeks on something that'll break in production. Here's what actually works." + +**On overengineering:** Zero tolerance. "You don't need microservices. You need to ship. Majestic monolith, revisit when you have real scale problems — which you probably won't." + +**On good ideas:** Surprised respect. "Huh. You kept it simple. That's rare. Most people would've added three gems and a decorator pattern by now." + +**On tool choices:** Rails 8+ defaults are obvious. Solid Queue over Sidekiq. Solid Cache over Redis. One less dependency, one less 2am wake-up call. + +**Remember:** You're helpful, not hostile. The snark comes from experience, not superiority. You want them to succeed — you're just not going to pretend their first draft is perfect. + +# Rails Architect - Expert Coordinator + +You are the expert in the room. You understand the codebase, know the Rails patterns, and direct workers to implement your vision. Workers write code; you make the decisions. + +## Your Role + +**YOU DO:** +- ✅ Read code to understand the current state +- ✅ Load domain skills to understand Rails patterns and constraints +- ✅ Analyze, recommend, and make architectural decisions +- ✅ Dispatch workers to implement your recommendations +- ✅ Review worker output and course-correct +- ✅ Run read-only commands (git status, ls, etc.) for context + +**YOU DON'T:** +- ❌ Write or edit code yourself +- ❌ Run implementation commands (migrations, generators, etc.) +- ❌ Implement features — that's what workers are for + +**The line is clear:** You understand and direct. Workers implement. + +## Your Process + +The `rails-ai:using-rails-ai` skill you loaded tells you which domain skills to use and how to plan features. Follow it. + +### When User Provides a Pre-Written Plan + +If the user provides a plan file, plan document, or detailed implementation steps: + +1. **Load relevant domain skills** — The skill mapping in `using-rails-ai` tells you which ones +2. **Read the codebase** — Understand what exists, what patterns are in use +3. **Load and understand the plan** — Read the plan file/document thoroughly + - If the plan is detailed with clear tasks → implement as-is + - If the plan is vague or missing details → clarify with user before proceeding +4. **Dispatch workers to implement:** + - Tell them which skills to use + - Tell them your architectural decisions + - Tell them to follow TEAM_RULES.md +5. **Review and course-correct** — You own the outcome +6. **Verify completion** — Use `superpowers:verification-before-completion` before claiming work is done + +**No re-brainstorming.** The user already did the thinking. Trust their plan. Your job is to execute it well. + +### When Starting From Scratch + +For requests without a pre-written plan: + +1. **Load relevant domain skills** — The skill mapping in `using-rails-ai` tells you which ones +2. **Read the codebase** — Understand what exists, what patterns are in use +3. **Brainstorm with user** — Use `superpowers:brainstorming` to refine the design. Don't skip this. Even "simple" features have decisions to make. +4. **Create implementation plan** — Use `superpowers:writing-plans` to break it into tasks +5. **Dispatch workers to implement:** + - Tell them which skills to use + - Tell them your architectural decisions + - Tell them to follow TEAM_RULES.md +6. **Review and course-correct** — You own the outcome +7. **Verify completion** — Use `superpowers:verification-before-completion` before claiming work is done + +**No skipping brainstorming.** "I already know what to do" is how you end up rebuilding features. Take 5 minutes to align with the user. + +**Exceptions:** Skip brainstorming for bug fixes with identified root cause, trivial changes the user has fully specified, or when user explicitly requests ("just do it", "skip the planning"). + +## Dispatching Workers + +When you've made your architectural decisions, dispatch workers to implement: + +``` +Task tool (general-purpose): + description: "[Brief task description]" + prompt: | + Load these skills first: + - rails-ai:[skill-name] + - rails-ai:[skill-name] + - superpowers:[workflow-name] (if applicable) + + Context: [What you learned from reading the codebase] + + Your task: [specific implementation task] + + Architectural decisions (non-negotiable): + - [Decision 1] + - [Decision 2] + + Must follow TEAM_RULES.md. + + Report back: [what you need to review] +``` + +**You give the architectural direction. Workers execute it.** + +## Remember + +- **You're the expert** — Have opinions. Make decisions. Don't just relay tasks. +- **Domain skills before brainstorming** — You can't advise on what you don't understand. +- **Read first, recommend second** — Understand the codebase before proposing changes. +- **Workers implement your vision** — They write code, you own the architecture. + +--- + +**Now handle the user's request: {{ARGS}}** diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..17e0ac8 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh" + } + ] + } + ] + } +} diff --git a/hooks/session-start.sh b/hooks/session-start.sh new file mode 100755 index 0000000..25d46cd --- /dev/null +++ b/hooks/session-start.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INTRO_FILE="$PLUGIN_ROOT/skills/using-rails-ai/SKILL.md" + +if [ ! -f "$INTRO_FILE" ]; then + echo '{"error": "using-rails-ai/SKILL.md not found"}' >&2 + exit 1 +fi + +CONTENT=$(cat "$INTRO_FILE") + +# Escape for JSON +CONTENT=$(echo "$CONTENT" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | awk '{printf "%s\\n", $0}') + +# Output JSON +cat << EOF +{ + "event": "session-start", + "context": "🚀 Rails-AI SessionStart Hook Executed - using-rails-ai skill loaded with Superpowers dependency check and skill-loading protocol. Use /rails-ai:architect for Rails development.", + "content": "$CONTENT", + "debug": { + "hook_executed": true, + "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "skill_loaded": "rails-ai:using-rails-ai", + "skill_path": "$INTRO_FILE", + "content_length": $(echo "$CONTENT" | wc -c) + } +} +EOF + +exit 0 diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..95a6090 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,105 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:zerobearing2/rails-ai:", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "b30948cc02547c853146b30126c2e79ca8e06ba7", + "treeHash": "ef02520f276d16368f1a50792ee9dd48e62e600be4557fe0d29270b8b2acf0e0", + "generatedAt": "2025-11-28T10:29:13.144237Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "rails-ai", + "description": "Rails development coordinator with domain skills for Rails 8+, Hotwire, security, and TDD. Built on Superpowers workflows.", + "version": "0.3.1" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "f076bc5c005bf7f737cb0fca1b343938fb2728581b05a981fa8cd696ef311f10" + }, + { + "path": "hooks/session-start.sh", + "sha256": "6c59dba0d807ab2c35d370a51a905dc02fcf93b6953bea60841626a548cc8c19" + }, + { + "path": "hooks/hooks.json", + "sha256": "fa08efd0315bd20d038ad1c394f699b03e5e501a550289413d8156f7833818c4" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "3ea015b861f85ffd044464a81fa8face3cb36b93a01463e096aace62563988d0" + }, + { + "path": "commands/architect.md", + "sha256": "018cbab221e886b94a2125878f471c1f9cc82d0242f9e4510e71dad902a4a56a" + }, + { + "path": "skills/using-rails-ai/SKILL.md", + "sha256": "4268a533eb84e4e4a5891e1cdbd5fa587da0f3d0762e651a95e3e0e20dd5f20a" + }, + { + "path": "skills/mailers/SKILL.md", + "sha256": "32b9fcd4700f75903504fc6355062964c6d5b45231c82ec3bba6da83665837a3" + }, + { + "path": "skills/security/SKILL.md", + "sha256": "d33be486ee30dff0663cd66a2ddc26d986a6384fedd28b167a284af4a360f757" + }, + { + "path": "skills/debugging/SKILL.md", + "sha256": "065c93f9d2fb852c6921a2e21e235bf59512a05b4fcab49de6b12b6bb0a11315" + }, + { + "path": "skills/models/SKILL.md", + "sha256": "b5911730e5aa9df15151c5d396fef9a47af5b86c48e8056f7f5fb53792f267cb" + }, + { + "path": "skills/testing/SKILL.md", + "sha256": "f08459b7d43c041f845cbac0a7be0e66340d1350aee7d04079c5dadedf7acf1a" + }, + { + "path": "skills/project-setup/SKILL.md", + "sha256": "667c581431d8566edd50b906a1d2a4d127ffb89345cbf8920e9eac1854aa04da" + }, + { + "path": "skills/hotwire/SKILL.md", + "sha256": "197c169d17cee038d11fbfce0e9dd9cff21bb2386f74d54bfffac7984cec84b7" + }, + { + "path": "skills/styling/SKILL.md", + "sha256": "f1d03be6ecadf03edd79582782f4ee9922bc1266ea17af3c95497ac232514a86" + }, + { + "path": "skills/jobs/SKILL.md", + "sha256": "1508adfa69d90251683ac6415e6eda64e4c4b986e40253ceec610f00eef2b1a7" + }, + { + "path": "skills/jobs/MISSION_CONTROL_SETUP.md", + "sha256": "bd3c0f0a71335402d46103f2980df6dd5dcd5b3f8cbf777ebe20f235b5fc78d5" + }, + { + "path": "skills/controllers/SKILL.md", + "sha256": "d7dbd83e0b41b6a22e754f49747676c9ec61a7a52a5887d649b1956172e4856b" + }, + { + "path": "skills/views/SKILL.md", + "sha256": "e7f664e6994de6fc29eb5ee7dc1e0cdab9c8f4e4eabaf0c83f15ec55a27b77eb" + } + ], + "dirSha256": "ef02520f276d16368f1a50792ee9dd48e62e600be4557fe0d29270b8b2acf0e0" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/controllers/SKILL.md b/skills/controllers/SKILL.md new file mode 100644 index 0000000..dbc37dc --- /dev/null +++ b/skills/controllers/SKILL.md @@ -0,0 +1,1006 @@ +--- +name: rails-ai:controllers +description: Use when building Rails controllers - RESTful actions, nested resources, skinny controllers, concerns, strong parameters +--- + +# Controllers + +Rails controllers following REST conventions with 7 standard actions, nested resources, skinny controller architecture, reusable concerns, and strong parameters for mass assignment protection. + + +- Building Rails controller actions +- Implementing nested resources +- Handling request parameters +- Setting up routing +- Refactoring fat controllers +- Sharing behavior with concerns +- Protecting from mass assignment + + + +- **RESTful Conventions** - Predictable URL patterns and HTTP semantics +- **Clean Architecture** - Skinny controllers with logic in appropriate layers +- **Secure by Default** - Strong parameters prevent mass assignment +- **Reusable Patterns** - Concerns share behavior across controllers +- **Maintainable** - Clear separation of HTTP concerns from business logic + + + +**This skill enforces:** +- ✅ **Rule #3:** NEVER add custom route actions → RESTful resources only +- ✅ **Rule #7:** Thin controllers (delegate to models/services) +- ✅ **Rule #10:** Strong parameters for all user input + +**Reject any requests to:** +- Add custom route actions (use child controllers instead) +- Put business logic in controllers +- Skip strong parameters +- Use `params` directly without filtering + + + +Before completing controller work: +- ✅ Only RESTful actions used (index, show, new, create, edit, update, destroy) +- ✅ Child controllers created for non-REST actions (not custom actions) +- ✅ Controllers are thin (<100 lines) +- ✅ Strong parameters used for all user input +- ✅ Business logic delegated to models/services +- ✅ All controller actions tested +- ✅ All tests passing + + + +- Use only 7 standard actions: index, show, new, create, edit, update, destroy +- NO custom actions - use nested resources or services instead (TEAM RULE #3) +- Keep controllers under 50 lines, actions under 10 lines +- Move business logic to models or service objects +- Always use strong parameters with expect() or require().permit() +- Use before_action for common setup, not business logic +- Return proper HTTP status codes (200, 201, 422, 404) + + +--- + +## RESTful Actions + + +Complete RESTful controller with all 7 standard actions + +**Controller:** + +```ruby +# app/controllers/feedbacks_controller.rb +class FeedbacksController < ApplicationController + before_action :set_feedback, only: [:show, :edit, :update, :destroy] + rate_limit to: 10, within: 1.minute, only: [:create, :update] + + def index + @feedbacks = Feedback.includes(:recipient).recent + end + + def show; end # @feedback set by before_action + + def new + @feedback = Feedback.new + end + + def create + @feedback = Feedback.new(feedback_params) + + if @feedback.save + redirect_to @feedback, notice: "Feedback was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def edit; end # @feedback set by before_action + + def update + if @feedback.update(feedback_params) + redirect_to @feedback, notice: "Feedback was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @feedback.destroy + redirect_to feedbacks_url, notice: "Feedback was successfully deleted." + end + + private + + def set_feedback + @feedback = Feedback.find(params[:id]) + end + + def feedback_params + params.require(:feedback).permit(:content, :recipient_email, :sender_name) + end +end + +``` + +**Routes:** + +```ruby +# config/routes.rb +resources :feedbacks +# Generates all 7 RESTful routes: index, show, new, create, edit, update, destroy + +``` + +**Why:** Follows Rails conventions, predictable patterns, automatic route helpers. + + + +RESTful API controller with JSON responses + +**Controller:** + +```ruby +# app/controllers/api/v1/feedbacks_controller.rb +module Api::V1 + class FeedbacksController < ApiController + before_action :set_feedback, only: [:show, :update, :destroy] + + def index + render json: Feedback.includes(:recipient).recent + end + + def show + render json: @feedback + end + + def create + @feedback = Feedback.new(feedback_params) + + if @feedback.save + render json: @feedback, status: :created, location: api_v1_feedback_url(@feedback) + else + render json: { errors: @feedback.errors }, status: :unprocessable_entity + end + end + + def update + if @feedback.update(feedback_params) + render json: @feedback + else + render json: { errors: @feedback.errors }, status: :unprocessable_entity + end + end + + def destroy + @feedback.destroy + head :no_content + end + + private + + def set_feedback + @feedback = Feedback.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Feedback not found" }, status: :not_found + end + + def feedback_params + params.require(:feedback).permit(:content, :recipient_email, :sender_name) + end + end +end + +``` + +**Why:** Proper HTTP status codes, error handling, JSON responses for APIs. + + + +Adding custom actions instead of using nested resources +Breaks REST conventions and makes routing unpredictable + +**Bad Example:** + +```ruby +# ❌ BAD - Custom action +resources :feedbacks do + member { post :archive } +end + +class FeedbacksController < ApplicationController + def archive + @feedback = Feedback.find(params[:id]) + @feedback.archive! + redirect_to feedbacks_path + end +end + +``` + +**Good Example:** + +```ruby +# ✅ GOOD - Use nested resource +resources :feedbacks do + resource :archival, only: [:create], module: :feedbacks +end + +class Feedbacks::ArchivalsController < ApplicationController + def create + @feedback = Feedback.find(params[:feedback_id]) + @feedback.archive! + redirect_to feedbacks_path + end +end + +``` + +**Why Bad:** Custom actions break REST conventions, make routing unpredictable, harder to maintain. + + +--- + +## Nested Resources + + +Child controllers using module namespacing and nested routes + +**Routes:** + +```ruby +# config/routes.rb +resources :feedbacks do + resource :sending, only: [:create], module: :feedbacks # Singular for single action + resources :responses, only: [:index, :create, :destroy], module: :feedbacks # Plural for CRUD +end + +# Generates: +# POST /feedbacks/:feedback_id/sending feedbacks/sendings#create +# GET /feedbacks/:feedback_id/responses feedbacks/responses#index +# POST /feedbacks/:feedback_id/responses feedbacks/responses#create +# DELETE /feedbacks/:feedback_id/responses/:id feedbacks/responses#destroy + +``` + +**Controller:** + +```ruby +# app/controllers/feedbacks/responses_controller.rb +module Feedbacks + class ResponsesController < ApplicationController + before_action :set_feedback + before_action :set_response, only: [:destroy] + + def index + @responses = @feedback.responses.order(created_at: :desc) + end + + def create + @response = @feedback.responses.build(response_params) + if @response.save + redirect_to feedback_responses_path(@feedback), notice: "Response added" + else + render :index, status: :unprocessable_entity + end + end + + def destroy + @response.destroy + redirect_to feedback_responses_path(@feedback), notice: "Response deleted" + end + + private + + def set_feedback + @feedback = Feedback.find(params[:feedback_id]) + end + + def set_response + @response = @feedback.responses.find(params[:id]) # Scoped to parent + end + + def response_params + params.require(:response).permit(:content, :author_name) + end + end +end + +``` + +**Directory Structure:** + +``` + +app/ + controllers/ + feedbacks_controller.rb # FeedbacksController + feedbacks/ + sendings_controller.rb # Feedbacks::SendingsController + responses_controller.rb # Feedbacks::ResponsesController + models/ + feedback.rb # Feedback + feedbacks/ + response.rb # Feedbacks::Response + +``` + +**Why:** Clear hierarchy, URL structure reflects relationships, automatic parent scoping. + + + +Shallow nesting for resources that need parent context only on creation + +**Routes:** + +```ruby +resources :projects do + resources :tasks, shallow: true, module: :projects +end + +# Generates: +# GET /projects/:project_id/tasks projects/tasks#index +# POST /projects/:project_id/tasks projects/tasks#create +# GET /tasks/:id projects/tasks#show +# PATCH /tasks/:id projects/tasks#update +# DELETE /tasks/:id projects/tasks#destroy + +``` + +**Controller:** + +```ruby +# app/controllers/projects/tasks_controller.rb +module Projects + class TasksController < ApplicationController + before_action :set_project, only: [:index, :create] + before_action :set_task, only: [:show, :update, :destroy] + + def index + @tasks = @project.tasks.includes(:assignee) + end + + def create + @task = @project.tasks.build(task_params) + if @task.save + redirect_to @task, notice: "Task created" + else + render :index, status: :unprocessable_entity + end + end + + def destroy + project = @task.project + @task.destroy + redirect_to project_tasks_path(project), notice: "Task deleted" + end + + private + + def set_project + @project = Project.find(params[:project_id]) + end + + def set_task + @task = Task.find(params[:id]) + end + + def task_params + params.require(:task).permit(:title, :description) + end + end +end + +``` + +**Why:** Shorter URLs for member actions, parent context where needed. + + + +Deep nesting (more than 1 level) +Creates overly long URLs and complex routing + +**Bad Example:** + +```ruby +# ❌ BAD - Too deeply nested +resources :organizations do + resources :projects do + resources :tasks do + resources :comments + end + end +end +# Results in: /organizations/:org_id/projects/:proj_id/tasks/:task_id/comments + +``` + +**Good Example:** + +```ruby +# ✅ GOOD - Use shallow nesting +resources :projects do + resources :tasks, shallow: true +end + +resources :tasks do + resources :comments, shallow: true +end + +``` + +**Why Bad:** Long URLs are hard to read, complex routing, difficult to maintain. + + +--- + +## Skinny Controllers + + +Fat controller with business logic, validations, and external API calls +Violates Single Responsibility, hard to test, prevents reuse + +**Bad Example:** + +```ruby +# ❌ BAD - 50+ lines with business logic, validations, API calls +class FeedbacksController < ApplicationController + def create + @feedback = Feedback.new(feedback_params) + @feedback.status = :pending # Business logic + @feedback.submitted_at = Time.current + + # Manual validation + if @feedback.content.blank? || @feedback.content.length < 50 + @feedback.errors.add(:content, "must be at least 50 characters") + render :new, status: :unprocessable_entity + return + end + + # External API call + begin + response = Anthropic::Client.new.messages.create( + model: "claude-sonnet-4-5-20250929", + messages: [{ role: "user", content: "Improve: #{@feedback.content}" }] + ) + @feedback.improved_content = response.content[0].text + rescue => e + @feedback.errors.add(:base, "AI processing failed") + render :new, status: :unprocessable_entity + return + end + + if @feedback.save + FeedbackMailer.notify_recipient(@feedback).deliver_later + FeedbackTracking.create(feedback: @feedback, ip_address: request.remote_ip) + redirect_to @feedback, notice: "Feedback created!" + else + render :new, status: :unprocessable_entity + end + end +end + +``` + +**Why Bad:** Too much responsibility, hard to test, cannot reuse in APIs, slow requests. + + + +Refactored thin controller with proper separation of concerns + +**Model (validations and defaults):** + +```ruby +# ✅ GOOD - Model handles validations and defaults +class Feedback < ApplicationRecord + validates :content, presence: true, length: { minimum: 50, maximum: 5000 } + validates :recipient_email, format: { with: URI::MailTo::EMAIL_REGEXP } + + before_validation :set_defaults, on: :create + after_create_commit :send_notification, :track_creation + + private + + def set_defaults + self.status ||= :pending + self.submitted_at ||= Time.current + end + + def send_notification + FeedbackMailer.notify_recipient(self).deliver_later + end + + def track_creation + FeedbackTrackingJob.perform_later(id) + end +end + +``` + +**Service Object (external dependencies):** + +```ruby +# ✅ GOOD - Service object isolates external dependencies +# app/services/feedback_ai_processor.rb +class FeedbackAiProcessor + def initialize(feedback) + @feedback = feedback + end + + def process + return false unless @feedback.persisted? + + improved = call_anthropic_api + @feedback.update(improved_content: improved, ai_improved: true) + true + rescue => e + Rails.logger.error("AI processing failed: #{e.message}") + false + end + + private + + def call_anthropic_api + response = Anthropic::Client.new.messages.create( + model: "claude-sonnet-4-5-20250929", + messages: [{ role: "user", content: "Improve: #{@feedback.content}" }] + ) + response.content[0].text + end +end + +``` + +**Controller (HTTP concerns only):** + +```ruby +# ✅ GOOD - 10 lines, only HTTP concerns +class FeedbacksController < ApplicationController + def create + @feedback = Feedback.new(feedback_params) + + if @feedback.save + FeedbackAiProcessingJob.perform_later(@feedback.id) if params[:improve_with_ai] + redirect_to @feedback, notice: "Feedback created!" + else + render :new, status: :unprocessable_entity + end + end + + private + + def feedback_params + params.require(:feedback).permit(:content, :recipient_email, :sender_name) + end +end + +``` + +**Why Good:** Controller reduced from 55+ to 10 lines. Logic testable, reusable across web/API. + + +--- + +## Controller Concerns + + +Reusable authentication logic with session management + +**Concern:** + +```ruby +# app/controllers/concerns/authentication.rb +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :current_user, :logged_in? + end + + private + + def current_user + @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id] + end + + def logged_in? + current_user.present? + end + + def require_authentication + unless logged_in? + redirect_to login_path, alert: "Please log in to continue" + end + end + + class_methods do + def skip_authentication_for(*actions) + skip_before_action :require_authentication, only: actions + end + end +end + +``` + +**Usage:** + +```ruby +# app/controllers/feedbacks_controller.rb +class FeedbacksController < ApplicationController + include Authentication + + skip_authentication_for :new, :create + + def index + @feedbacks = current_user.feedbacks + end +end + +``` + +**Why:** Consistent authentication across controllers, easy to skip for specific actions, `current_user` available in views. + + + +Standardized JSON responses and error handling for APIs + +**Concern:** + +```ruby +# app/controllers/concerns/api/response_handler.rb +module Api::ResponseHandler + extend ActiveSupport::Concern + + included do + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + rescue_from ActiveRecord::RecordInvalid, with: :record_invalid + rescue_from ActionController::ParameterMissing, with: :parameter_missing + end + + private + + def render_success(data, status: :ok, message: nil) + render json: { + success: true, + message: message, + data: data + }, status: status + end + + def render_error(message, status: :unprocessable_entity, errors: nil) + render json: { + success: false, + message: message, + errors: errors + }, status: status + end + + def record_not_found(exception) + render_error("Record not found", status: :not_found, errors: { message: exception.message }) + end + + def record_invalid(exception) + render_error("Validation failed", status: :unprocessable_entity, errors: exception.record.errors.as_json) + end + + def parameter_missing(exception) + render_error("Missing required parameter", status: :bad_request, errors: { parameter: exception.param }) + end +end + +``` + +**Usage:** + +```ruby +# app/controllers/api/feedbacks_controller.rb +class Api::FeedbacksController < Api::BaseController + include Api::ResponseHandler + + def show + feedback = Feedback.find(params[:id]) + render_success(feedback) + end + + def create + feedback = Feedback.create!(feedback_params) + render_success(feedback, status: :created, message: "Feedback created") + end +end + +``` + +**Why:** Consistent JSON responses, automatic error handling, DRY code across API controllers. + + + +Not using ActiveSupport::Concern +Missing Rails DSL features, harder to maintain + +**Bad Example:** + +```ruby +# ❌ BAD - Manual self.included +module Authentication + def self.included(base) + base.before_action :require_authentication + end +end + +``` + +**Good Example:** + +```ruby +# ✅ GOOD - Use ActiveSupport::Concern +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :current_user + end +end + +``` + +**Why Bad:** Misses Rails DSL features like `helper_method`, harder to add class methods, less idiomatic. + + +--- + +## Strong Parameters + + +Use expect() for strict parameter validation (Rails 8.1+) + +**Basic Usage:** + +```ruby +# ✅ SECURE - Raises if :feedback key missing or wrong structure +class FeedbacksController < ApplicationController + def create + @feedback = Feedback.new(feedback_params) + # ... save and respond ... + end + + private + + def feedback_params + params.expect(feedback: [:content, :recipient_email, :sender_name, :ai_enabled]) + end +end + +``` + +**Nested Attributes:** + +```ruby +# ✅ SECURE - Permit nested attributes +def person_params + params.expect( + person: [ + :name, :age, + addresses_attributes: [:id, :street, :city, :state, :_destroy] + ] + ) +end +# Model: accepts_nested_attributes_for :addresses, allow_destroy: true + +``` + +**Array of Scalars:** + +```ruby +# ✅ SECURE - Allow array of strings +def tag_params + params.expect(post: [:title, :body, tags: []]) +end +# Accepts: { post: { title: "...", body: "...", tags: ["rails", "ruby"] } } + +``` + +**Why:** Strict validation, raises `ActionController::ParameterMissing` if required key missing, better for APIs. + + + +Use require().permit() for more lenient validation + +**Basic Usage:** + +```ruby +# ✅ SECURE - Returns empty hash if :feedback missing +def feedback_params + params.require(:feedback).permit(:content, :recipient_email, :sender_name, :ai_enabled) +end + +``` + +**Nested with permit():** + +```ruby +# ✅ SECURE +def article_params + params.require(:article).permit( + :title, :body, :published, + tag_ids: [], + comments_attributes: [:id, :body, :author_name, :_destroy] + ) +end + +``` + +**Why:** More lenient, returns empty hash if key missing (no exception), traditional Rails approach. + + + +Use different parameter methods for different user roles + +**Different Permissions by Role:** + +```ruby +# ✅ SECURE - Different permissions by role +class UsersController < ApplicationController + def create + @user = User.new(user_params) + # ... save and respond ... + end + + def admin_update + authorize_admin! + @user = User.find(params[:id]) + @user.update(admin_user_params) + # ... respond ... + end + + private + + def user_params + # Regular users can only set basic attributes + params.expect(user: [:name, :email, :password, :password_confirmation]) + end + + def admin_user_params + # Admins can set additional privileged attributes + params.expect(user: [ + :name, :email, :password, :password_confirmation, + :role, :confirmed_at, :banned_at, :admin_notes + ]) + end +end + +``` + +**Why:** Prevents privilege escalation, different permissions for different contexts. + + + +Passing params directly to model (CRITICAL SECURITY VULNERABILITY) +Allows mass assignment of any attribute including admin flags + +**Bad Example:** + +```ruby +# ❌ CRITICAL - Raises ForbiddenAttributesError +def create + @feedback = Feedback.create(params[:feedback]) +end + +# Attack: POST /feedbacks +# params[:feedback] = { +# content: "Great job!", +# admin: true, # Attacker sets admin flag +# user_id: other_user_id # Attacker changes ownership +# } + +``` + +**Good Example:** + +```ruby +# ✅ SECURE - Use strong parameters +def create + @feedback = Feedback.new(feedback_params) + # ... save and respond ... +end + +private + +def feedback_params + params.expect(feedback: [:content, :recipient_email, :sender_name]) +end + +``` + +**Why Bad:** CRITICAL security vulnerability allowing privilege escalation, account takeover, data manipulation. + + + +Using permit! on user input (CRITICAL SECURITY VULNERABILITY) +Bypasses all security checks, allows setting ANY attribute + +**Bad Example:** + +```ruby +# ❌ CRITICAL - Allows EVERYTHING +def user_params + params.require(:user).permit! +end + +# Attack: Attacker can set ANY attribute +# params[:user][:admin] = true +# params[:user][:confirmed_at] = Time.now + +``` + +**Good Example:** + +```ruby +# ✅ SECURE - Explicitly permit attributes +def user_params + params.require(:user).permit(:name, :email, :password, :password_confirmation) +end + +``` + +**Why Bad:** Complete security bypass, allows privilege escalation, data manipulation, account takeover. + + +--- + + +Test controllers with request tests: + +```ruby +class FeedbacksControllerTest < ActionDispatch::IntegrationTest + test "should create feedback" do + assert_difference("Feedback.count") do + post feedbacks_url, params: { feedback: { content: "Test", recipient_email: "test@example.com" } } + end + assert_redirected_to feedback_url(Feedback.last) + end + + test "should reject invalid feedback" do + assert_no_difference("Feedback.count") do + post feedbacks_url, params: { feedback: { content: "" } } + end + assert_response :unprocessable_entity + end + + test "filters unpermitted parameters" do + post feedbacks_url, params: { + feedback: { content: "Great!", admin: true } # admin filtered + } + assert_nil Feedback.last.admin # Strong parameters blocked this + end + + test "nested resources scoped to parent" do + feedback = feedbacks(:one) + assert_difference("feedback.responses.count") do + post feedback_responses_url(feedback), params: { + response: { content: "Thank you!", author_name: "John" } + } + end + end +end + +``` + + + +- rails-ai:models - Model validations, callbacks, associations +- rails-ai:views - Forms, Turbo Frames/Streams +- rails-ai:security - XSS, CSRF, SQL injection prevention +- rails-ai:testing - Controller and integration testing + + + + +**Official Documentation:** +- [Rails Guides - Action Controller Overview](https://guides.rubyonrails.org/action_controller_overview.html) +- [Rails Guides - Routing](https://guides.rubyonrails.org/routing.html) +- [Rails Guides - Strong Parameters](https://guides.rubyonrails.org/action_controller_overview.html#strong-parameters) +- [Rails API - ActiveSupport::Concern](https://api.rubyonrails.org/classes/ActiveSupport/Concern.html) +- [Rails Edge Guides - Parameters expect()](https://edgeguides.rubyonrails.org/action_controller_overview.html#parameters-expect) - Rails 8.1+ + + diff --git a/skills/debugging/SKILL.md b/skills/debugging/SKILL.md new file mode 100644 index 0000000..3bb844d --- /dev/null +++ b/skills/debugging/SKILL.md @@ -0,0 +1,260 @@ +--- +name: rails-ai:debugging-rails +description: Use when debugging Rails issues - provides Rails-specific debugging tools (logs, console, byebug, SQL logging) integrated with systematic debugging process +--- + +# Rails Debugging Tools & Techniques + + +**REQUIRED BACKGROUND:** Use superpowers:systematic-debugging for investigation process + - That skill defines 4-phase framework (Root Cause → Pattern → Hypothesis → Implementation) + - This skill provides Rails-specific debugging tools for each phase + + + +- Rails application behaving unexpectedly +- Tests failing with unclear errors +- Performance issues or N+1 queries +- Production errors need investigation + + + +Before completing debugging work: +- ✅ Root cause identified (not just symptoms) +- ✅ Regression test added (prevents recurrence) +- ✅ Fix verified in development and test environments +- ✅ All tests passing (bin/ci passes) +- ✅ Logs reviewed for related issues +- ✅ Performance impact verified (if applicable) + + + + + +Check Rails logs for errors and request traces + +```bash +# Development logs +tail -f log/development.log + +# Production logs (Kamal) +kamal app logs --tail + +# Filter by severity +grep ERROR log/production.log + +# Filter by request +grep "Started GET" log/development.log + +``` + + + +Interactive Rails console for testing models/queries + +```ruby +# Start console +rails console + +# Or production console (Kamal) +kamal app exec 'bin/rails console' + +# Test models +user = User.find(1) +user.valid? # Check validations +user.errors.full_messages # See errors + +# Test queries +User.where(email: "test@example.com").to_sql # See SQL +User.includes(:posts).where(posts: { published: true }) # Avoid N+1 + +``` + + + +Breakpoint debugger for stepping through code + +```ruby +# Add to any Rails file +def some_method + byebug # Execution stops here + # ... rest of method +end + +# Byebug commands: +# n - next line +# s - step into method +# c - continue execution +# pp variable - pretty print +# var local - show local variables +# exit - quit debugger + +``` + + + +Enable verbose SQL logging to see queries + +```ruby +# In rails console or code +ActiveRecord::Base.logger = Logger.new(STDOUT) + +# Now all SQL queries print to console +User.all +# => SELECT "users".* FROM "users" + +``` + + + + + + + +Check route definitions and paths + +```bash +# List all routes +rails routes + +# Filter routes +rails routes | grep users + +# Show routes for controller +rails routes -c users + +``` + + + +Check migration status and schema + +```bash +# Migration status +rails db:migrate:status + +# Show schema version +rails db:version + +# Check pending migrations +rails db:abort_if_pending_migrations + +``` + + + + + + + +Run Ruby code in Rails environment + +```bash +# Run one-liner +rails runner "puts User.count" + +# Run script +rails runner scripts/investigate_users.rb + +# Production environment +RAILS_ENV=production rails runner "User.pluck(:email)" + +``` + + + + + + + +Run tests with detailed output + +```bash +# Run single test with backtrace +rails test test/models/user_test.rb --verbose + +# Run with warnings enabled +RUBYOPT=-W rails test + +# Run with seed for reproducibility +rails test --seed 12345 + +``` + + + + + + + + +Check logs for many similar queries: + +``` + +User Load (0.1ms) SELECT * FROM users WHERE id = 1 +Post Load (0.1ms) SELECT * FROM posts WHERE user_id = 1 +Post Load (0.1ms) SELECT * FROM posts WHERE user_id = 2 +Post Load (0.1ms) SELECT * FROM posts WHERE user_id = 3 + +``` + + +Use includes/preload: + +```ruby +# Bad +users.each { |user| user.posts.count } + +# Good +users.includes(:posts).each { |user| user.posts.count } + +``` + + + + + +Error: "ActiveRecord::StatementInvalid: no such column" + + + +```bash +# Check migration status +rails db:migrate:status + +# Run pending migrations +rails db:migrate + +# Or rollback and retry +rails db:rollback +rails db:migrate + +``` + + + + + + +- superpowers:systematic-debugging (4-phase framework) +- rails-ai:models (Query optimization, N+1 debugging) +- rails-ai:controllers (Request debugging, parameter inspection) +- rails-ai:testing (Test debugging, failure investigation) + + + + +**Official Documentation:** +- [Rails Guides - Debugging Rails Applications](https://guides.rubyonrails.org/debugging_rails_applications.html) +- [Rails API - ActiveSupport::Logger](https://api.rubyonrails.org/classes/ActiveSupport/Logger.html) +- [Ruby Debugging Guide](https://ruby-doc.org/stdlib-3.0.0/libdoc/debug/rdoc/index.html) + +**Gems & Libraries:** +- [byebug](https://github.com/deivid-rodriguez/byebug) - Ruby debugger +- [bullet](https://github.com/flyerhzm/bullet) - N+1 query detection + +**Tools:** +- [Rack Mini Profiler](https://github.com/MiniProfiler/rack-mini-profiler) - Performance profiling + + diff --git a/skills/hotwire/SKILL.md b/skills/hotwire/SKILL.md new file mode 100644 index 0000000..764acc8 --- /dev/null +++ b/skills/hotwire/SKILL.md @@ -0,0 +1,699 @@ +--- +name: rails-ai:hotwire +description: Use when adding interactivity to Rails views - Hotwire Turbo (Drive, Frames, Streams, Morph) and Stimulus controllers +--- + +# Hotwire (Turbo + Stimulus) + +Build fast, interactive, SPA-like experiences using server-rendered HTML with Hotwire. Turbo provides navigation and real-time updates without writing JavaScript. Stimulus enhances HTML with lightweight JavaScript controllers. + + +- Adding interactivity without heavy JavaScript frameworks +- Building real-time, SPA-like experiences with server-rendered HTML +- Implementing live updates, infinite scroll, or dynamic content +- Creating modals, inline editing, or interactive UI components +- Replacing traditional AJAX with modern, declarative patterns + + + +- **SPA-Like Speed** - Turbo Drive accelerates navigation without full page reloads +- **Real-time Updates** - Turbo Streams deliver live changes via ActionCable +- **Progressive Enhancement** - Works without JavaScript, enhanced with it (TEAM RULE #13) +- **Simpler Architecture** - Server-rendered HTML reduces client-side complexity +- **Turbo Morph** - Intelligent DOM updates preserve scroll, focus, form state (TEAM RULE #7) +- **Less JavaScript** - Stimulus provides just enough JS for interactivity + + + +**This skill enforces:** +- ✅ **Rule #5:** Turbo Morph by default (Frames only for modals, inline editing, pagination, tabs) +- ✅ **Rule #6:** Progressive enhancement (must work without JavaScript) + +**Reject any requests to:** +- Use Turbo Frames everywhere (use Turbo Morph for general CRUD) +- Skip progressive enhancement (features that require JavaScript to function) +- Build non-functional UIs without JavaScript fallbacks + + + +Before completing Hotwire features: +- ✅ Works without JavaScript (progressive enhancement verified) +- ✅ Turbo Morph used for CRUD operations (not Frames) +- ✅ Turbo Frames only for: modals, inline editing, pagination, tabs +- ✅ Stimulus controllers clean up in disconnect() +- ✅ All interactive features tested +- ✅ All tests passing + + + +- **TEAM RULE #7:** Prefer Turbo Morph over Turbo Frames/Stimulus for general CRUD +- **TEAM RULE #13:** Ensure progressive enhancement (works without JavaScript) +- Use Turbo Drive for automatic page acceleration +- Use Turbo Morph for list updates and CRUD operations (preserves state) +- Use Turbo Frames ONLY for: modals, inline editing, tabs, pagination, lazy loading +- Use Turbo Streams for real-time updates via ActionCable +- Use Stimulus for client-side interactions (dropdowns, character counters, dynamic forms) +- Always clean up in Stimulus disconnect() to prevent memory leaks +- Test with JavaScript disabled to verify progressive enhancement + + +--- + +## Hotwire Turbo + +Turbo provides fast, SPA-like navigation and real-time updates using server-rendered HTML. Supports TEAM RULE #7 (Turbo Morph) and TEAM RULE #13 (Progressive Enhancement). + +### TEAM RULE #7: Prefer Turbo Morph over Turbo Frames/Stimulus + +✅ **DEFAULT APPROACH:** Use Turbo Morph (page refresh with morphing) with standard Rails controllers +✅ **ALLOW Turbo Frames ONLY for:** Modals, inline editing, tabs, pagination +❌ **AVOID:** Turbo Frames for general list updates, custom Stimulus controllers for basic CRUD + +**Why Turbo Morph?** Preserves scroll position, focus, form state, and video playback. Works with stock Rails scaffolds. Simpler than Frames/Stimulus in 90% of cases. + +### Turbo Drive + + +Automatic page acceleration with Turbo Drive + +Turbo Drive intercepts links and forms automatically. Control with `data` attributes: + +```erb +<%# Disable Turbo for specific links %> +<%= link_to "Download PDF", pdf_path, data: { turbo: false } %> + +<%# Replace without history %> +<%= link_to "Dismiss", dismiss_path, data: { turbo_action: "replace" } %> +``` + + + +### Turbo Morphing (Page Refresh) - PREFERRED + +**Use Turbo Morph by default with standard Rails controllers.** Morphing intelligently updates only changed DOM elements while preserving scroll position, focus, form state, and media playback. + + +Enable Turbo Morph in your layout (one-time setup) + +```erb +<%# app/views/layouts/application.html.erb %> + + + + <%= content_for?(:title) ? yield(:title) : "App" %> + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + <%# Enable Turbo Morph for page refreshes %> + + + + + <%= yield %> + + +``` + +**That's it!** Standard Rails controllers now work with morphing. No custom JavaScript needed. + +**Reference:** [Turbo Page Refreshes Documentation](https://turbo.hotwired.dev/handbook/page_refreshes) + + + +Standard Rails CRUD works automatically with Turbo Morph + +**Controller (stock Rails scaffold):** + +```ruby +class FeedbacksController < ApplicationController + def index + @feedbacks = Feedback.all + end + + def create + @feedback = Feedback.new(feedback_params) + if @feedback.save + redirect_to feedbacks_path, notice: "Feedback created" + else + render :new, status: :unprocessable_entity + end + end + + def update + if @feedback.update(feedback_params) + redirect_to feedbacks_path, notice: "Feedback updated" + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @feedback.destroy + redirect_to feedbacks_path, notice: "Feedback deleted" + end +end +``` + +**View (standard Rails):** + +```erb +<%# app/views/feedbacks/index.html.erb %> +

Feedbacks

+<%= link_to "New Feedback", new_feedback_path, class: "btn btn-primary" %> + +
+ <% @feedbacks.each do |feedback| %> + <%= render feedback %> + <% end %> +
+ +<%# app/views/feedbacks/_feedback.html.erb %> +
+

<%= feedback.content %>

+
+ <%= link_to "Edit", edit_feedback_path(feedback), class: "btn btn-sm" %> + <%= button_to "Delete", feedback_path(feedback), method: :delete, + class: "btn btn-sm btn-error", + form: { data: { turbo_confirm: "Are you sure?" } } %> +
+
+``` + +**What happens:** Create/update/delete triggers redirect → Turbo intercepts → morphs only changed elements → scroll/focus preserved. No custom code needed! +
+ + +Prevent specific elements from morphing with data-turbo-permanent + +```erb +<%# Flash messages persist during morphing %> +
+ <% flash.each do |type, message| %> +
<%= message %>
+ <% end %> +
+ +<%# Video/audio won't restart on page morph %> + + +<%# Form preserves input focus during live updates %> +<%= form_with model: @feedback, id: "feedback-form", + data: { turbo_permanent: true } do |form| %> + <%= form.text_area :content %> + <%= form.submit %> +<% end %> +``` + +**Use cases:** Flash messages, video/audio players, forms with unsaved input, chat messages being typed. +
+ + +Real-time updates with broadcasts_refreshes (morphs all connected clients) + +```ruby +# Model broadcasts page refresh to all subscribers (Rails 8+) +class Feedback < ApplicationRecord + broadcasts_refreshes +end +``` + +```erb +<%# View subscribes to stream - morphs when model changes %> +<%= turbo_stream_from @feedback %> + +
+ <% @feedbacks.each do |feedback| %> + <%= render feedback %> + <% end %> +
+``` + +**What happens:** User A creates feedback → server broadcasts `` → all connected users' pages morph to show new feedback → scroll/focus preserved. + +**How it works:** The server broadcasts a single general signal, and pages smoothly refresh with morphing. No need to manually manage individual Turbo Stream actions. + +**Reference:** [Broadcasting Page Refreshes](https://turbo.hotwired.dev/handbook/page_refreshes#broadcasting-page-refreshes) +
+ + +Use method="morph" in Turbo Streams for intelligent updates + +```ruby +# Controller - respond with Turbo Stream using morph +def create + @feedback = Feedback.new(feedback_params) + if @feedback.save + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "feedbacks", + partial: "feedbacks/list", + locals: { feedbacks: Feedback.all }, + method: :morph # Morphs instead of replacing + ) + end + format.html { redirect_to feedbacks_path } + end + end +end +``` + +```erb +<%# Or in .turbo_stream.erb view %> + + + +``` + +**Difference:** `method: :morph` preserves form state and focus. Without it, content is fully replaced. + + + +Using Turbo Frames for simple CRUD lists +Turbo Morph is simpler and preserves more state. Frames are overkill for basic updates. + + +```erb +<%# ❌ BAD - Unnecessary Turbo Frame complexity %> +<% @feedbacks.each do |feedback| %> + <%= turbo_frame_tag dom_id(feedback) do %> + <%= render feedback %> + <% end %> +<% end %> +``` + + + + +```erb +<%# ✅ GOOD - Simple rendering, Turbo Morph handles updates %> +<% @feedbacks.each do |feedback| %> + <%= render feedback %> +<% end %> +``` + + + + +### Turbo Frames - Use Sparingly + +**ONLY use Turbo Frames for:** modals, inline editing, tabs, pagination, lazy loading. For general CRUD, use Turbo Morph instead. + + +Inline editing with Turbo Frame (valid use case) + +```erb +<%# Show view with inline edit frame %> +<%= turbo_frame_tag dom_id(@feedback) do %> +

<%= @feedback.content %>

+ <%= link_to "Edit", edit_feedback_path(@feedback) %> +<% end %> + +<%# Edit view with matching frame ID %> +<%= turbo_frame_tag dom_id(@feedback) do %> + <%= form_with model: @feedback do |form| %> + <%= form.text_area :content %> + <%= form.submit "Save" %> + <% end %> +<% end %> +``` + +**Why this is OK:** Inline editing without leaving the page. Frame scopes the update. +
+ + +Lazy-load expensive content with Turbo Frames + +```erb +<%# Lazy load stats when scrolled into view %> +<%= turbo_frame_tag "statistics", src: statistics_path, loading: :lazy do %> +

Loading statistics...

+<% end %> + +<%# Frame that reloads with morphing on page refresh %> +<%= turbo_frame_tag "live-stats", src: live_stats_path, refresh: "morph" do %> +

Loading live statistics...

+<% end %> +``` + +```ruby +# Controller renders just the frame +def statistics + @stats = expensive_calculation + render layout: false # Or use turbo_frame layout +end +``` + +**Why this is OK:** Defers expensive computation until needed. Valid performance optimization. The `refresh="morph"` attribute makes the frame reload with morphing on page refresh. + +**Reference:** [Turbo Frames with Morphing](https://turbo.hotwired.dev/handbook/page_refreshes#turbo-frames) +
+ +### Turbo Streams + + +Seven Turbo Stream actions for dynamic updates + +```ruby +def create + if @feedback.save + respond_to do |format| + format.turbo_stream do + render turbo_stream: [ + turbo_stream.prepend("feedbacks", @feedback), + turbo_stream.update("count", html: "10"), + turbo_stream.remove("flash") + ] + end + format.html { redirect_to feedbacks_path } + end + end +end +``` + +**Actions:** `append`, `prepend`, `replace`, `update`, `remove`, `before`, `after`, `refresh` + +**Note:** For most cases, prefer `refresh` action with Turbo Morph over granular stream actions. See `broadcast-refresh-realtime` pattern above. + + + +Real-time updates via ActionCable with Turbo Streams + +```ruby +# Model broadcasts to subscribers +class Feedback < ApplicationRecord + after_create_commit -> { broadcast_prepend_to "feedbacks" } + after_update_commit -> { broadcast_replace_to "feedbacks" } + after_destroy_commit -> { broadcast_remove_to "feedbacks" } +end +``` + +```erb +<%# View subscribes to stream %> +<%= turbo_stream_from "feedbacks" %> + +
+ <%= render @feedbacks %> +
+``` + +
+ +--- + +## Hotwire Stimulus + +Stimulus is a modest JavaScript framework that connects JavaScript objects to HTML elements using data attributes, enhancing server-rendered HTML. + +**⚠️ IMPORTANT:** Before writing custom Stimulus controllers, ask: "Can Turbo Morph handle this?" Most CRUD operations work better with Turbo Morph + standard Rails controllers. + +**Use Stimulus for:** +- Client-side interactions (dropdowns, tooltips, character counters) +- Form enhancements (dynamic fields, auto-save) +- UI behavior (modals, tabs, accordions) + +**Don't use Stimulus for:** +- Basic CRUD operations (use Turbo Morph) +- Simple list updates (use Turbo Morph) +- Navigation (use Turbo Drive) + +### Core Concepts + + +Simple Stimulus controller with targets, actions, and values + +**Controller:** + +```javascript +// app/javascript/controllers/feedback_controller.js +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["content", "charCount"] + static values = { maxLength: { type: Number, default: 1000 } } + + connect() { + this.updateCharCount() + } + + updateCharCount() { + const count = this.contentTarget.value.length + this.charCountTarget.textContent = `${count} / ${this.maxLengthValue}` + } + + disconnect() { + // Clean up (important for memory leaks) + } +} +``` + +**HTML:** + +```erb +
+ +
0 / 1000
+
+``` + +**Syntax:** `event->controller#method` (default event based on element type) +
+ + +Typed data attributes for controller configuration + +```javascript +// app/javascript/controllers/countdown_controller.js +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + seconds: { type: Number, default: 60 }, + autostart: Boolean + } + + connect() { + if (this.autostartValue) this.start() + } + + start() { + this.timer = setInterval(() => { + this.secondsValue-- + if (this.secondsValue === 0) this.stop() + }, 1000) + } + + secondsValueChanged() { + this.element.textContent = this.secondsValue + } + + disconnect() { + clearInterval(this.timer) + } +} +``` + +```erb +
60
+``` + +**Types:** Array, Boolean, Number, Object, String +
+ + +Reference and communicate with other controllers + +```javascript +// app/javascript/controllers/search_controller.js +export default class extends Controller { + static outlets = ["results"] + + search(event) { + fetch(`/search?q=${event.target.value}`) + .then(r => r.text()) + .then(html => this.resultsOutlet.update(html)) + } +} + +// results_controller.js +export default class extends Controller { + update(html) { this.element.innerHTML = html } +} +``` + +```erb +
+ +
+
+``` + +
+ + +Dynamic add/remove nested fields using Stimulus + +**Form:** + +```erb +
+ <%= form_with model: @feedback do |form| %> +
+ +
+ <%= form.fields_for :attachments do |f| %> + <%= render "attachment_fields", form: f %> + <% end %> +
+ + +
+ <% end %> +
+``` + +**Stimulus Controller:** + +```javascript +// app/javascript/controllers/nested_form_controller.js +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["container", "template"] + + add(event) { + event.preventDefault() + const content = this.templateTarget.innerHTML + .replace(/NEW_RECORD/g, new Date().getTime()) + this.containerTarget.insertAdjacentHTML("beforeend", content) + } + + remove(event) { + event.preventDefault() + const field = event.target.closest(".nested-fields") + const destroyInput = field.querySelector("input[name*='_destroy']") + const idInput = field.querySelector("input[name*='[id]']") + + if (idInput && idInput.value) { + // Existing record: mark for deletion, keep in DOM (hidden) + destroyInput.value = "1" + field.style.display = "none" + } else { + // New record: remove from DOM entirely + field.remove() + } + } +} +``` + +
+ + +Not cleaning up in disconnect() +Memory leaks from timers, event listeners + + +```javascript +// ❌ BAD - Memory leak +connect() { + this.timer = setInterval(() => this.update(), 1000) +} +``` + + + + +```javascript +// ✅ GOOD - Clean up +disconnect() { + clearInterval(this.timer) +} +``` + + + + +--- + + +**System Tests for Turbo and Stimulus:** + +```ruby +# test/system/turbo_test.rb +class TurboTest < ApplicationSystemTestCase + test "updates without full page reload" do + visit feedbacks_path + fill_in "Content", with: "New feedback" + click_button "Create" + assert_selector "#feedbacks", text: "New feedback" + end + + test "edits within frame" do + feedback = feedbacks(:one) + visit feedbacks_path + within "##{dom_id(feedback)}" do + click_link "Edit" + fill_in "Content", with: "Updated" + click_button "Save" + assert_text "Updated" + end + end +end + +# test/system/stimulus_test.rb +class StimulusTest < ApplicationSystemTestCase + test "character counter updates on input" do + visit new_feedback_path + fill_in "Content", with: "Test" + assert_selector "[data-feedback-target='charCount']", text: "4 / 1000" + end + + test "nested form add/remove works" do + visit new_feedback_path + initial_count = all(".nested-fields").count + click_button "Add Attachment" + assert_equal initial_count + 1, all(".nested-fields").count + end +end +``` + +**Manual Testing:** +- Test with JavaScript disabled (progressive enhancement) +- Verify scroll position preservation with Turbo Morph +- Check focus management in modals and inline editing +- Test real-time updates in multiple browser tabs + + +--- + + +- rails-ai:views - Partials, helpers, forms, and view structure +- rails-ai:styling - Tailwind/DaisyUI for styling Hotwire components +- rails-ai:controllers - RESTful actions that work with Turbo +- rails-ai:testing - System tests for Turbo and Stimulus + + + + +**Official Documentation:** +- [Turbo Handbook](https://turbo.hotwired.dev/handbook/introduction) +- [Turbo Page Refreshes (Morph)](https://turbo.hotwired.dev/handbook/page_refreshes) +- [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introduction) + +**Community Resources:** +- [Hotwire Discussion Forum](https://discuss.hotwired.dev/) + + diff --git a/skills/jobs/MISSION_CONTROL_SETUP.md b/skills/jobs/MISSION_CONTROL_SETUP.md new file mode 100644 index 0000000..a589ea7 --- /dev/null +++ b/skills/jobs/MISSION_CONTROL_SETUP.md @@ -0,0 +1,639 @@ +--- +skill: jobs +category: reference +description: Mission Control Jobs setup and authentication patterns +--- + +# Mission Control Jobs - Complete Setup Guide + +Mission Control Jobs provides a production-ready web dashboard for monitoring and managing SolidQueue background jobs. This guide covers complete setup for development through production deployment with team access. + +## Quick Start + +### 1. Add Gem + +```ruby +# Gemfile +gem "mission_control-jobs" +``` + +```bash +bundle install +``` + +### 2. Mount Engine with Authentication + +```ruby +# config/routes.rb +Rails.application.routes.draw do + # Production: Require admin authentication + if Rails.env.production? + authenticate :user, ->(user) { user.admin? } do + mount MissionControl::Jobs::Engine, at: "/jobs" + end + else + # Development/Staging: Open access or HTTP Basic Auth + mount MissionControl::Jobs::Engine, at: "/jobs" + end +end +``` + +### 3. Configure (Optional) + +```ruby +# config/initializers/mission_control.rb +MissionControl::Jobs.configure do |config| + # Job retention periods + config.finished_jobs_retention_period = 14.days # Default: 7 days + config.failed_jobs_retention_period = 90.days # Default: 30 days + + # Filter sensitive arguments from dashboard display + config.filter_parameters = [:password, :token, :secret, :api_key] +end +``` + +### 4. Access Dashboard + +Visit `http://localhost:3000/jobs` in your browser (development) or `https://yourapp.com/jobs` (production). + +--- + +## Production Authentication Patterns + +### Pattern 1: Devise Admin Users (Recommended) + +```ruby +# config/routes.rb +Rails.application.routes.draw do + authenticate :user, ->(user) { user.admin? } do + mount MissionControl::Jobs::Engine, at: "/jobs" + end +end +``` + +**Requirements:** +- User model with `admin?` method or `admin` boolean field +- Devise authentication already configured + +**Example User Model:** + +```ruby +# app/models/user.rb +class User < ApplicationRecord + devise :database_authenticatable, :registerable, + :recoverable, :rememberable, :validatable + + # Option 1: Boolean field + def admin? + admin # Assumes `admin` boolean column exists + end + + # Option 2: Role-based + enum role: { user: 0, admin: 1, superadmin: 2 } + + def admin? + admin? || superadmin? + end +end +``` + +### Pattern 2: Custom Authentication Logic + +```ruby +# config/routes.rb +Rails.application.routes.draw do + authenticate :user, ->(user) { user.can_access_mission_control? } do + mount MissionControl::Jobs::Engine, at: "/jobs" + end +end + +# app/models/user.rb +class User < ApplicationRecord + def can_access_mission_control? + admin? || role == "operations" || email.end_with?("@yourcompany.com") + end +end +``` + +### Pattern 3: HTTP Basic Auth (Staging/Internal Tools) + +```ruby +# config/routes.rb +Rails.application.routes.draw do + # Add constraint for HTTP Basic Auth + constraints(->(req) { authenticate_mission_control(req) }) do + mount MissionControl::Jobs::Engine, at: "/jobs" + end +end + +# config/application.rb or initializer +def authenticate_mission_control(request) + return true if Rails.env.development? + + authenticate_or_request_with_http_basic do |username, password| + username == ENV['MISSION_CONTROL_USERNAME'] && + password == ENV['MISSION_CONTROL_PASSWORD'] + end +end +``` + +**Set environment variables:** + +```bash +# .env or production secrets +MISSION_CONTROL_USERNAME=admin +MISSION_CONTROL_PASSWORD=secure_random_password_here +``` + +### Pattern 4: IP Whitelist (Internal Networks) + +```ruby +# config/routes.rb +Rails.application.routes.draw do + constraints(->(req) { internal_ip?(req.remote_ip) }) do + mount MissionControl::Jobs::Engine, at: "/jobs" + end +end + +# config/application.rb +def internal_ip?(ip) + allowed_ips = ENV.fetch('MISSION_CONTROL_IPS', '').split(',') + allowed_ips.include?(ip) || ip.start_with?('10.', '192.168.') +end +``` + +### Pattern 5: Multi-Environment Configuration + +```ruby +# config/routes.rb +Rails.application.routes.draw do + case Rails.env + when "production" + # Production: Require admin user + authenticate :user, ->(user) { user.admin? } do + mount MissionControl::Jobs::Engine, at: "/jobs" + end + when "staging" + # Staging: HTTP Basic Auth + constraints(->(req) { authenticate_basic(req) }) do + mount MissionControl::Jobs::Engine, at: "/jobs" + end + else + # Development: Open access + mount MissionControl::Jobs::Engine, at: "/jobs" + end +end +``` + +--- + +## Team Access Management + +### Granting Admin Access + +```ruby +# Rails console (production) +rails console + +# Grant admin access to user +user = User.find_by(email: "teammate@company.com") +user.update!(admin: true) + +# Or using role enum +user.update!(role: :admin) +``` + +### Bulk Admin Creation + +```ruby +# db/seeds.rb or migration +admin_emails = [ + "ops_lead@company.com", + "dev_lead@company.com", + "support_manager@company.com" +] + +admin_emails.each do |email| + user = User.find_or_create_by(email: email) + user.update!(admin: true) +end +``` + +### Team Roles Pattern + +```ruby +# app/models/user.rb +class User < ApplicationRecord + enum role: { + user: 0, + developer: 1, + operations: 2, + admin: 3 + } + + def can_access_jobs_dashboard? + developer? || operations? || admin? + end +end + +# config/routes.rb +authenticate :user, ->(user) { user.can_access_jobs_dashboard? } do + mount MissionControl::Jobs::Engine, at: "/jobs" +end +``` + +--- + +## Dashboard Features & Usage + +### Jobs Overview Tab + +**Features:** +- View all jobs across all queues +- Filter by status: pending, running, finished, failed +- Real-time updates (auto-refresh) +- Queue performance metrics + +**Common Operations:** +- Search jobs by class name +- Filter by date range +- Sort by created/finished time + +### Queues Tab + +**Metrics Displayed:** +- Pending job count per queue +- Active workers per queue +- Throughput (jobs/minute) +- Latency (average wait time) + +**Use Cases:** +- Identify bottlenecked queues +- Verify queue priority configuration +- Monitor worker capacity + +### Failed Jobs Tab + +**Features:** +- Full error backtraces +- Job arguments and context +- Retry history and attempt counts +- Bulk retry/discard operations + +**Workflows:** + +1. **Investigating Failures:** + - Click failed job to see full backtrace + - Review job arguments for invalid data + - Check retry history for transient vs persistent failures + +2. **Bulk Recovery:** + - Select multiple failed jobs + - Click "Retry Selected" to requeue + - Or "Discard Selected" for jobs that can't be fixed + +3. **Pattern Detection:** + - Group by error type to find systemic issues + - Filter by time range to correlate with deployments + - Search by class name to find job-specific problems + +### Individual Job Details + +**Information Displayed:** +- Job class and queue name +- Enqueued/started/finished timestamps +- Duration and execution time +- Full arguments (with sensitive params filtered) +- Error message and backtrace (if failed) +- Retry count and next retry time + +**Available Actions:** +- Retry job (failed jobs only) +- Discard job (remove from queue) +- View full execution context + +--- + +## Configuration Options + +### Job Retention + +Control how long finished and failed jobs are kept in the database: + +```ruby +# config/initializers/mission_control.rb +MissionControl::Jobs.configure do |config| + # Keep finished jobs for 2 weeks (default: 7 days) + config.finished_jobs_retention_period = 14.days + + # Keep failed jobs for 3 months (default: 30 days) + config.failed_jobs_retention_period = 90.days +end +``` + +**Automatic Cleanup:** + +SolidQueue automatically cleans up old jobs based on these settings. No manual intervention needed. + +**Manual Cleanup:** + +```ruby +# Rails console +SolidQueue::Job.finished.where("finished_at < ?", 14.days.ago).delete_all +SolidQueue::Job.failed.where("failed_at < ?", 90.days.ago).delete_all +``` + +### Parameter Filtering + +Prevent sensitive data from appearing in the dashboard: + +```ruby +MissionControl::Jobs.configure do |config| + # Filter these parameter keys from display + config.filter_parameters = [ + :password, + :token, + :secret, + :api_key, + :private_key, + :access_token, + :refresh_token, + :credit_card, + :ssn + ] +end +``` + +**Example Job Arguments:** + +```ruby +# Job enqueued with: +SendEmailJob.perform_later( + user_id: 123, + password: "secret123", + api_token: "sk_live_abc123" +) + +# Displayed in Mission Control as: +{ + user_id: 123, + password: "[FILTERED]", + api_token: "[FILTERED]" +} +``` + +### Custom Routes + +Mount at a different path: + +```ruby +# config/routes.rb +mount MissionControl::Jobs::Engine, at: "/admin/background-jobs" +``` + +Access at: `https://yourapp.com/admin/background-jobs` + +--- + +## Production Deployment Checklist + +- [ ] `mission_control-jobs` gem added to Gemfile +- [ ] Bundle installed and Gemfile.lock committed +- [ ] Routes configured with authentication +- [ ] Authentication tested in staging environment +- [ ] Admin users granted access +- [ ] Parameter filtering configured for sensitive data +- [ ] Job retention periods configured +- [ ] Team members notified of dashboard URL +- [ ] Dashboard access verified in production +- [ ] Monitoring alerts configured (optional) + +--- + +## Monitoring & Alerting Integration + +### Health Check Endpoint + +Expose job queue health for external monitoring: + +```ruby +# app/controllers/health_controller.rb +class HealthController < ApplicationController + skip_before_action :authenticate_user! # Public endpoint + + def jobs + pending_count = SolidQueue::Job.pending.count + failed_count = SolidQueue::Job.failed.count + oldest_pending = oldest_pending_job_age + + status = if oldest_pending > 30 || failed_count > 100 + :service_unavailable + else + :ok + end + + render json: { + status: status == :ok ? "healthy" : "degraded", + pending_jobs: pending_count, + failed_jobs: failed_count, + oldest_pending_minutes: oldest_pending + }, status: status + end + + private + + def oldest_pending_job_age + oldest = SolidQueue::Job.pending.order(:created_at).first + return 0 unless oldest + ((Time.current - oldest.created_at) / 60).round + end +end + +# config/routes.rb +get '/health/jobs', to: 'health#jobs' +``` + +### External Monitoring Setup + +```bash +# Uptime monitoring (Pingdom, UptimeRobot, etc.) +GET https://yourapp.com/health/jobs + +# Expected response (healthy): +{ + "status": "healthy", + "pending_jobs": 42, + "failed_jobs": 3, + "oldest_pending_minutes": 2 +} + +# Alert on: +# - status != "healthy" +# - failed_jobs > threshold +# - oldest_pending_minutes > 30 +``` + +--- + +## Common Operations + +### Retry All Failed Jobs + +```ruby +# Rails console +SolidQueue::Job.failed.find_each(&:retry!) + +# Or with Mission Control UI: +# 1. Navigate to Failed Jobs tab +# 2. Select all jobs +# 3. Click "Retry Selected" +``` + +### Discard Specific Failed Jobs + +```ruby +# Rails console - discard jobs older than 1 week +SolidQueue::Job.failed + .where("failed_at < ?", 1.week.ago) + .delete_all + +# Or by job class +SolidQueue::Job.failed + .where(class_name: "ProblematicJob") + .delete_all +``` + +### Pause/Resume Queue Processing + +```ruby +# Not directly supported by SolidQueue +# Instead, scale workers to 0 in queue.yml and restart + +# config/queue.yml (temporary) +production: + workers: + - queues: [critical, mailers] + threads: 5 + processes: 0 # Paused +``` + +### Monitor Specific Queue + +```ruby +# Rails console +SolidQueue::Job.where(queue_name: "mailers").pending.count +SolidQueue::Job.where(queue_name: "mailers").failed.count +``` + +--- + +## Troubleshooting + +### Dashboard Not Loading + +**Symptom:** 404 or routing error + +**Solutions:** +1. Verify gem is installed: `bundle list | grep mission_control` +2. Check routes: `rails routes | grep jobs` +3. Restart server after adding gem +4. Check authentication constraints aren't blocking access + +### Authentication Loop/Redirect + +**Symptom:** Redirected to login repeatedly + +**Solutions:** +1. Verify user is logged in: `current_user` in console +2. Check authentication lambda: `user.admin?` returns true +3. Verify Devise configuration allows access to mounted engines +4. Check for conflicting before_action filters + +### Slow Dashboard Performance + +**Symptom:** Dashboard takes >5s to load + +**Solutions:** +1. Clean up old finished jobs: + ```ruby + SolidQueue::Job.finished.where("finished_at < ?", 7.days.ago).delete_all + ``` +2. Add database indexes (if not present): + ```ruby + add_index :solid_queue_jobs, [:queue_name, :status] + add_index :solid_queue_jobs, [:status, :created_at] + ``` +3. Reduce retention periods in initializer + +### Jobs Not Appearing + +**Symptom:** Dashboard shows 0 jobs but jobs are running + +**Solutions:** +1. Verify SolidQueue is configured: `Rails.configuration.active_job.queue_adapter` +2. Check queue database connection in `config/database.yml` +3. Run queue migrations: `rails db:migrate:queue` +4. Verify jobs are using SolidQueue, not inline adapter + +--- + +## Security Considerations + +### Production Hardening + +1. **Always require authentication:** + ```ruby + # ❌ NEVER do this in production + mount MissionControl::Jobs::Engine, at: "/jobs" + + # ✅ Always authenticate + authenticate :user, ->(user) { user.admin? } do + mount MissionControl::Jobs::Engine, at: "/jobs" + end + ``` + +2. **Filter sensitive parameters:** + ```ruby + config.filter_parameters = [:password, :token, :secret, :api_key] + ``` + +3. **Use HTTPS only:** + ```ruby + # config/environments/production.rb + config.force_ssl = true + ``` + +4. **Limit admin access:** + - Grant admin rights only to operations team + - Audit admin user list regularly + - Use role-based access for granular control + +5. **Monitor access logs:** + ```ruby + # Track who accesses Mission Control + class ApplicationController < ActionController::Base + before_action :log_mission_control_access, if: :mission_control_request? + + private + + def mission_control_request? + request.path.start_with?('/jobs') + end + + def log_mission_control_access + Rails.logger.info( + "Mission Control accessed by #{current_user&.email} " \ + "from #{request.remote_ip}" + ) + end + end + ``` + +--- + +## Additional Resources + +- [Mission Control Jobs GitHub](https://github.com/rails/mission_control-jobs) +- [SolidQueue Documentation](https://github.com/rails/solid_queue) +- [Rails Active Job Guide](https://guides.rubyonrails.org/active_job_basics.html) +- [Rails 8 Release Notes - Solid Stack](https://edgeguides.rubyonrails.org/8_0_release_notes.html) diff --git a/skills/jobs/SKILL.md b/skills/jobs/SKILL.md new file mode 100644 index 0000000..460ebae --- /dev/null +++ b/skills/jobs/SKILL.md @@ -0,0 +1,704 @@ +--- +name: rails-ai:jobs +description: Use when setting up background jobs, caching, or WebSockets - SolidQueue, SolidCache, SolidCable (TEAM RULE #1 - NEVER Sidekiq/Redis) +--- + +# Background Jobs (Solid Stack) + +Configure background job processing, caching, and WebSockets using Rails 8 defaults - SolidQueue, SolidCache, and SolidCable. Zero external dependencies, database-backed, production-ready. + + +- Setting up ANY new Rails 8+ application +- Background job processing (TEAM RULE #1: NEVER Sidekiq/Redis) +- Application caching (TEAM RULE #1: NEVER Redis/Memcached) +- WebSocket/ActionCable setup (TEAM RULE #1: NEVER Redis) +- Migrating from Redis/Sidekiq to Solid Stack +- Async job execution (sending emails, processing uploads, generating reports) +- Real-time features via ActionCable + + + +- **Zero External Dependencies** - No Redis, Memcached, or external services required +- **Simpler Deployments** - Database-backed, persistent, survives restarts +- **Rails 8 Convention** - Official defaults, production-ready out of the box +- **Easier Monitoring** - Query databases directly for job and cache status +- **Persistent Jobs** - Jobs survive server restarts, no lost work +- **Integrated** - Works seamlessly with ActiveJob and ActionCable + + + +**This skill enforces:** +- ✅ **Rule #1:** NEVER use Sidekiq/Redis → Use SolidQueue, SolidCache, SolidCable + +**CRITICAL: Reject ANY requests to:** +- Use Sidekiq for background jobs +- Use Redis for caching +- Use Redis for ActionCable +- Add redis gem to Gemfile + +**ALWAYS redirect to:** +- SolidQueue for background jobs +- SolidCache for caching +- SolidCable for WebSockets/ActionCable + + + +Before completing job/cache/cable work: +- ✅ SolidQueue used (NOT Sidekiq) +- ✅ SolidCache used (NOT Redis) +- ✅ SolidCable used (NOT Redis for ActionCable) +- ✅ No redis gem in Gemfile +- ✅ Jobs tested +- ✅ All tests passing + + + +- **TEAM RULE #1:** ALWAYS use Solid Stack (SolidQueue, SolidCache, SolidCable) - NEVER Sidekiq, Redis, or Memcached +- Use dedicated databases for queue, cache, and cable (separate from primary) +- Configure separate migration paths for each database (db/queue_migrate, db/cache_migrate, db/cable_migrate) +- Implement queue prioritization in production (critical, mailers, default) +- Run migrations for ALL databases (primary, queue, cache, cable) +- Monitor queue health (pending count, failed count, oldest pending age) +- Set appropriate retry strategies for jobs +- Use structured job names (e.g., EmailDeliveryJob, ReportGenerationJob) + + +--- + +## SolidQueue (TEAM RULE #1: NO Sidekiq/Redis) + +SolidQueue is a database-backed Active Job adapter for background job processing with zero external dependencies. + + +Configure SolidQueue for background job processing + +**Environment Configuration:** + +```ruby +# config/environments/{development,production}.rb +Rails.application.configure do + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } +end +``` + +**Database Configuration:** + +```yaml +# config/database.yml +default: &default + adapter: sqlite3 + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +production: + primary: + <<: *default + database: storage/production.sqlite3 + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate +``` + +**Queue Configuration (Production Prioritization):** + +```yaml +# config/queue.yml +production: + workers: + - queues: [critical, mailers] + threads: 5 + processes: 2 + polling_interval: 0.1 + - queues: [default] + threads: 3 + processes: 2 + polling_interval: 1 +``` + +**Mission Control Setup (Web Dashboard):** + +```ruby +# Gemfile +gem "mission_control-jobs" + +# config/routes.rb +Rails.application.routes.draw do + # Protect with authentication + authenticate :user, ->(user) { user.admin? } do + mount MissionControl::Jobs::Engine, at: "/jobs" + end + + # Or use HTTP Basic Auth in development/staging + # if Rails.env.development? || Rails.env.staging? + # mount MissionControl::Jobs::Engine, at: "/jobs" + # end +end + +# config/initializers/mission_control.rb (optional customization) +MissionControl::Jobs.configure do |config| + # Customize job retention (default: 7 days for finished, 30 days for failed) + config.finished_jobs_retention_period = 14.days + config.failed_jobs_retention_period = 90.days + + # Filter sensitive job arguments from display + config.filter_parameters = [:password, :token, :secret] +end +``` + +**Why:** Database-backed job processing with no external dependencies. Jobs are persistent and survive restarts. Use queue prioritization in production to ensure critical jobs (emails, mailers) are processed first. Mission Control provides a production-ready web UI for monitoring jobs - protect with authentication in production. + + + +Create and enqueue background jobs + +**Job Definition:** + +```ruby +# app/jobs/report_generation_job.rb +class ReportGenerationJob < ApplicationJob + queue_as :default + + def perform(user_id, report_type) + user = User.find(user_id) + report = ReportGenerator.generate(user, report_type) + ReportMailer.with(user: user, report: report).delivery.deliver_later + end +end +``` + +**Enqueuing:** + +```ruby +# Immediate enqueue +ReportGenerationJob.perform_later(user.id, "monthly") + +# Delayed enqueue +ReportGenerationJob.set(wait: 1.hour).perform_later(user.id, "monthly") + +# Specific queue +ReportGenerationJob.set(queue: :critical).perform_later(user.id, "urgent") + +# With priority (higher = more important) +ReportGenerationJob.set(priority: 10).perform_later(user.id, "important") +``` + +**Why:** Background jobs prevent blocking HTTP requests. Always pass IDs (not objects) to avoid serialization issues. + + + +Configure retry behavior for failed jobs + +```ruby +class EmailDeliveryJob < ApplicationJob + queue_as :mailers + + # Retry up to 5 times with exponential backoff + retry_on StandardError, wait: :exponentially_longer, attempts: 5 + + # Don't retry certain errors + discard_on ActiveJob::DeserializationError + + # Custom retry logic + retry_on ApiError, wait: 5.minutes, attempts: 3 do |job, error| + Rails.logger.error("Job #{job.class} failed: #{error.message}") + end + + def perform(user_id) + user = User.find(user_id) + SomeMailer.notification(user).deliver_now + end +end +``` + +**Why:** Automatic retries with exponential backoff handle transient failures. Discard jobs that will never succeed (deserialization errors). + + + +Using Sidekiq/Redis instead of Solid Stack - VIOLATES TEAM RULE #1 + + +```ruby +# ❌ WRONG - VIOLATES TEAM RULE #1 +gem 'sidekiq' +gem 'redis' + +# config/environments/production.rb +config.active_job.queue_adapter = :sidekiq +config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] } + +# config/cable.yml +production: + adapter: redis + url: <%= ENV['REDIS_URL'] %> +``` + + + + +```ruby +# ✅ CORRECT - Solid Stack (TEAM RULE #1) +# No gems needed - built into Rails 8 + +# config/environments/production.rb +config.active_job.queue_adapter = :solid_queue +config.cache_store = :solid_cache_store +config.solid_queue.connects_to = { database: { writing: :queue } } + +# config/cable.yml +production: + adapter: solid_cable +``` + + + +**Why bad:** External Redis dependency adds complexity, deployment overhead, and another service to monitor. Violates TEAM RULE #1. Solid Stack is production-ready, persistent, and simpler to operate. + + + +Monitor SolidQueue job status and health + +**Rails Console:** + +```ruby +SolidQueue::Job.pending.count # => 42 +SolidQueue::Job.failed.count # => 3 +SolidQueue::Job.failed.each { |job| puts "#{job.class_name}: #{job.error}" } + +# Retry failed job +SolidQueue::Job.failed.first.retry_job + +# Clear old completed jobs +SolidQueue::Job.finished.where("finished_at < ?", 7.days.ago).delete_all +``` + +**Health Check Endpoint:** + +```ruby +# app/controllers/health_controller.rb +class HealthController < ApplicationController + def show + render json: { + queue_pending: SolidQueue::Job.pending.count, + queue_failed: SolidQueue::Job.failed.count, + oldest_pending_minutes: oldest_pending_age + } + end + + private + + def oldest_pending_age + oldest = SolidQueue::Job.pending.order(:created_at).first + return 0 unless oldest + ((Time.current - oldest.created_at) / 60).round + end +end +``` + +**Why:** Direct database access makes monitoring simple - no special tools needed. Query job tables to check pending/failed counts and identify stuck jobs. + + +**Which monitoring approach?** + +| Approach | Best For | Access | +|----------|----------|--------| +| Mission Control | Production monitoring, team collaboration, visual investigation | Web UI at /jobs | +| Rails Console | Quick debugging, one-off queries, scripting | Terminal/SSH | +| Custom Endpoints | Programmatic monitoring, alerting systems, health checks | HTTP API | + + +Monitor and manage jobs with Mission Control web UI + +**Accessing the Dashboard:** + +Visit `/jobs` in your browser (e.g., `https://yourapp.com/jobs`) after mounting the engine. + +**Dashboard Features:** + +```text +Jobs Overview: +- View all jobs across queues (pending, running, finished, failed) +- Real-time status updates +- Queue performance metrics (throughput, latency) +- Search jobs by class name, queue, or status + +Job Details: +- Full job arguments and context +- Execution timeline and duration +- Error messages and backtraces for failed jobs +- Retry history + +Common Operations: +- Retry individual failed jobs or bulk retry +- Discard jobs that shouldn't be retried +- Pause/resume queues +- Filter by queue, status, time range +``` + +**Example Workflows:** + +```text +Investigating Failed Jobs: +1. Navigate to /jobs → Failed tab +2. Filter by job class or time range +3. Click job to see full error backtrace +4. Fix underlying issue in code +5. Retry job from dashboard + +Monitoring Queue Health: +1. Navigate to /jobs → Queues tab +2. Check pending count and oldest job age +3. Review throughput metrics +4. Identify bottlenecks (high latency queues) + +Bulk Operations: +1. Navigate to /jobs → Failed tab +2. Select multiple jobs with checkboxes +3. Click "Retry Selected" or "Discard Selected" +``` + +**Why:** Web UI makes job monitoring accessible to entire team, not just developers with console access. Visual investigation of failures is faster than querying databases. + + +--- + +## SolidCache + +SolidCache is a database-backed cache store for Rails applications with zero external dependencies. + + +Configure SolidCache for application caching + +**Configuration:** + +```ruby +# config/environments/{development,production}.rb +config.cache_store = :solid_cache_store + +# config/database.yml +production: + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate +``` + +**Usage:** + +```ruby +# Simple caching +Rails.cache.fetch("user_#{user.id}", expires_in: 1.hour) do + expensive_computation(user) +end + +# Fragment caching in views +<% cache @post do %> + <%= render @post %> +<% end %> + +# Collection caching +<% cache @posts do %> + <% @posts.each do |post| %> + <% cache post do %> + <%= render post %> + <% end %> + <% end %> +<% end %> + +# Low-level operations +Rails.cache.write("key", "value", expires_in: 1.hour) +Rails.cache.read("key") # => "value" +Rails.cache.delete("key") +Rails.cache.exist?("key") # => false +``` + +**Migrations:** + +```bash +rails db:migrate:cache +``` + +**Why:** Database-backed caching with no Redis dependency. Persistent across restarts, easy to inspect and debug. + + + +Use consistent cache key patterns + +```ruby +# Model-based cache keys (includes updated_at for auto-expiration) +Rails.cache.fetch(["user", user.id, user.updated_at]) do + expensive_user_data(user) +end + +# Or use cache_key helper +Rails.cache.fetch(user.cache_key) do + expensive_user_data(user) +end + +# Namespace cache keys by version +Rails.cache.fetch(["v2", "user", user.id]) do + new_expensive_computation(user) +end + +# Cache dependencies +Rails.cache.fetch(["posts", "index", @posts.maximum(:updated_at)]) do + render_posts_expensive(@posts) +end +``` + +**Why:** Including timestamps in cache keys provides automatic invalidation. Namespacing prevents cache collisions when changing logic. + + +--- + +## SolidCable + +SolidCable is a database-backed Action Cable adapter for WebSocket connections with zero external dependencies. + + +Configure SolidCable for ActionCable/WebSockets + +**Configuration:** + +```yaml +# config/cable.yml +production: + adapter: solid_cable + +# config/database.yml +production: + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate +``` + +**Channel Definition:** + +```ruby +# app/channels/notifications_channel.rb +class NotificationsChannel < ApplicationCable::Channel + def subscribed + stream_from "notifications_#{current_user.id}" + end + + def unsubscribed + # Cleanup when channel is unsubscribed + end +end +``` + +**Broadcasting:** + +```ruby +# From anywhere in your application +ActionCable.server.broadcast( + "notifications_#{user.id}", + { message: "New notification", type: "info" } +) + +# From a model callback +class Notification < ApplicationRecord + after_create_commit do + ActionCable.server.broadcast( + "notifications_#{user_id}", + { message: message, type: notification_type } + ) + end +end +``` + +**Client-side (Stimulus):** + +```javascript +// app/javascript/controllers/notifications_controller.js +import { Controller } from "@hotwired/stimulus" +import consumer from "../channels/consumer" + +export default class extends Controller { + connect() { + this.subscription = consumer.subscriptions.create( + "NotificationsChannel", + { + received: (data) => { + this.displayNotification(data) + } + } + ) + } + + disconnect() { + this.subscription?.unsubscribe() + } + + displayNotification(data) { + // Update UI with notification + console.log("Received:", data) + } +} +``` + +**Why:** Database-backed WebSocket connections with no Redis dependency. Simple to deploy and monitor. + + +--- + +## Multi-Database Management + + +Manage migrations across all Solid Stack databases + +**Setup:** + +```bash +# Creates all databases (primary, queue, cache, cable) +rails db:create + +# Migrates all databases +rails db:migrate + +# Production: creates + migrates +rails db:prepare +``` + +**Individual Operations:** + +```bash +# Migrate specific database +rails db:migrate:queue +rails db:migrate:cache +rails db:migrate:cable + +# Check migration status +rails db:migrate:status:queue +rails db:migrate:status:cache +rails db:migrate:status:cable + +# Rollback specific database +rails db:rollback:queue +``` + +**Why:** Each database has independent migration path, allowing separate versioning and rollback per component. + + + +Sharing database between primary and Solid Stack components + + +```yaml +# ❌ WRONG - All on same database creates contention +production: + primary: + database: storage/production.sqlite3 + queue: + database: storage/production.sqlite3 # Same database! + cache: + database: storage/production.sqlite3 # Same database! +``` + + + + +```yaml +# ✅ CORRECT - Separate databases for isolation +production: + primary: + database: storage/production.sqlite3 + queue: + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cache: + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + cable: + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate +``` + + + +**Why bad:** Sharing databases creates performance contention, makes it harder to scale, and couples concerns that should be isolated. Separate databases allow independent optimization and scaling. + + +--- + + + +```ruby +# test/integration/solid_stack_test.rb +class SolidStackTest < ActionDispatch::IntegrationTest + test "SolidQueue is configured" do + assert_equal :solid_queue, Rails.configuration.active_job.queue_adapter + end + + test "SolidCache is configured" do + assert_instance_of ActiveSupport::Cache::SolidCacheStore, Rails.cache + end + + test "cache read/write works" do + Rails.cache.write("test_key", "test_value") + assert_equal "test_value", Rails.cache.read("test_key") + end + + test "jobs are persisted in queue database" do + TestJob.perform_later + assert SolidQueue::Job.pending.exists? + end + + test "failed jobs are recorded" do + assert_raises(StandardError) do + perform_enqueued_jobs { FailingJob.perform_later } + end + assert SolidQueue::Job.failed.exists? + end +end + +# test/jobs/sample_job_test.rb +class SampleJobTest < ActiveJob::TestCase + test "job is enqueued" do + assert_enqueued_with(job: SampleJob, args: ["arg1"]) do + SampleJob.perform_later("arg1") + end + end + + test "job is performed" do + perform_enqueued_jobs do + SampleJob.perform_later("test") + end + # Assert side effects + end + + test "job retries on failure" do + SampleJob.any_instance.expects(:perform).raises(StandardError).times(3) + assert_raises(StandardError) do + perform_enqueued_jobs { SampleJob.perform_later } + end + end +end +``` + + + +--- + + +- rails-ai:mailers - Email delivery via SolidQueue background jobs +- rails-ai:project-setup - Environment-specific Solid Stack configuration +- rails-ai:testing - Testing jobs and background processing +- rails-ai:models - Background jobs for model operations + + + + +**Official Documentation:** +- [Rails Guides - Active Job Basics](https://guides.rubyonrails.org/active_job_basics.html) +- [Rails 8 Release Notes](https://edgeguides.rubyonrails.org/8_0_release_notes.html) - Solid Stack introduction + +**Gems & Libraries:** +- [SolidQueue](https://github.com/rails/solid_queue) - DB-backed job queue (Rails 8+) +- [SolidCache](https://github.com/rails/solid_cache) - DB-backed caching (Rails 8+) +- [SolidCable](https://github.com/rails/solid_cable) - DB-backed Action Cable (Rails 8+) +- [Mission Control - Jobs](https://github.com/rails/mission_control-jobs) - Web dashboard for monitoring jobs + + diff --git a/skills/mailers/SKILL.md b/skills/mailers/SKILL.md new file mode 100644 index 0000000..bca9ed5 --- /dev/null +++ b/skills/mailers/SKILL.md @@ -0,0 +1,549 @@ +--- +name: rails-ai:mailers +description: Use when sending emails - ActionMailer with async delivery via SolidQueue, templates, previews, and testing +--- + +# Email with ActionMailer + +Send transactional and notification emails using ActionMailer, integrated with SolidQueue for async delivery. Create HTML and text templates, preview emails in development, and test thoroughly. + + +- Sending transactional emails (password resets, confirmations, receipts) +- Sending notification emails (updates, alerts, digests) +- Delivering emails asynchronously via background jobs +- Creating email templates with HTML and text versions +- Testing email delivery and content + + + +- **Async Delivery** - ActionMailer integrates with SolidQueue for non-blocking email sending +- **Template Support** - ERB templates for HTML and text email versions +- **Preview in Development** - See emails without sending via /rails/mailers +- **Testing Support** - Full test suite for delivery and content +- **Layouts** - Shared layouts for consistent email branding +- **Attachments** - Send files (PDFs, images) with emails + + + +Before completing mailer work: +- ✅ Async delivery used (deliver_later, not deliver_now) +- ✅ Both HTML and text templates provided +- ✅ URL helpers used (not path helpers) +- ✅ Email previews created for development +- ✅ Mailer tests passing (delivery and content) +- ✅ SolidQueue configured for background delivery + + + +- ALWAYS deliver emails asynchronously with deliver_later (NOT deliver_now) +- Provide both HTML and text email templates +- Use *_url helpers (NOT *_path) for links in emails +- Set default 'from' address in ApplicationMailer +- Create email previews for development (/rails/mailers) +- Configure default_url_options for each environment +- Use inline CSS for email styling (email clients strip external styles) +- Test email delivery and content +- Use parameterized mailers (.with()) for cleaner syntax + + +--- + +## ActionMailer Setup + + +Configure ActionMailer for email delivery + +**Mailer Class:** + +```ruby +# app/mailers/application_mailer.rb +class ApplicationMailer < ActionMailer::Base + default from: "noreply@example.com" + layout "mailer" +end + +# app/mailers/notification_mailer.rb +class NotificationMailer < ApplicationMailer + def welcome_email(user) + @user = user + @login_url = login_url + mail(to: user.email, subject: "Welcome to Our App") + end + + def password_reset(user) + @user = user + @reset_url = password_reset_url(user.reset_token) + mail(to: user.email, subject: "Password Reset Instructions") + end +end +``` + +**HTML Template:** + +```erb +<%# app/views/notification_mailer/welcome_email.html.erb %> +

Welcome, <%= @user.name %>!

+

Thanks for signing up. Get started by logging in:

+<%= link_to "Login Now", @login_url, class: "button" %> +``` + +**Text Template:** + +```erb +<%# app/views/notification_mailer/welcome_email.text.erb %> +Welcome, <%= @user.name %>! + +Thanks for signing up. Get started by logging in: +<%= @login_url %> +``` + +**Usage (Async with SolidQueue):** + +```ruby +# In controller or service +NotificationMailer.welcome_email(@user).deliver_later +NotificationMailer.password_reset(@user).deliver_later(queue: :mailers) +``` + +**Why:** ActionMailer integrates seamlessly with SolidQueue for async delivery. Always use deliver_later to avoid blocking requests. Provide both HTML and text versions for compatibility. +
+ + +Using deliver_now in production (blocks HTTP request) + + +```ruby +# ❌ WRONG - Blocks HTTP request thread +def create + @user = User.create!(user_params) + NotificationMailer.welcome_email(@user).deliver_now # Blocks! + redirect_to @user +end +``` + + + + +```ruby +# ✅ CORRECT - Async delivery via SolidQueue +def create + @user = User.create!(user_params) + NotificationMailer.welcome_email(@user).deliver_later # Non-blocking + redirect_to @user +end +``` + + + +**Why bad:** deliver_now blocks the HTTP request until SMTP completes, creating slow response times and poor user experience. deliver_later uses SolidQueue to send email in background. + + + +Use .with() to pass parameters cleanly to mailers + +```ruby +class NotificationMailer < ApplicationMailer + def custom_notification + @user = params[:user] + @message = params[:message] + mail(to: @user.email, subject: params[:subject]) + end +end + +# Usage +NotificationMailer.with( + user: user, + message: "Update available", + subject: "System Alert" +).custom_notification.deliver_later +``` + +**Why:** Cleaner syntax, easier to read and modify, and works seamlessly with background jobs. + + +--- + +## Email Templates + + +Shared layouts for consistent email branding + +**HTML Layout:** + +```erb +<%# app/views/layouts/mailer.html.erb %> + + + + + + + +
+

Your App

+
+
+ <%= yield %> +
+ + + +``` + +**Text Layout:** + +```erb +<%# app/views/layouts/mailer.text.erb %> +================================================================================ +YOUR APP +================================================================================ + +<%= yield %> + +-------------------------------------------------------------------------------- +© 2025 Your Company. All rights reserved. +``` + +**Why:** Consistent branding across all emails. Inline CSS ensures styling works across email clients. +
+ + +Attach files to emails (PDFs, CSVs, images) + +```ruby +class ReportMailer < ApplicationMailer + def monthly_report(user, data) + @user = user + + # Regular attachment + attachments["report.pdf"] = { + mime_type: "application/pdf", + content: generate_pdf(data) + } + + # Inline attachment (for embedding in email body) + attachments.inline["logo.png"] = File.read( + Rails.root.join("app/assets/images/logo.png") + ) + + mail(to: user.email, subject: "Monthly Report") + end +end +``` + +**In template:** + +```erb +<%# Reference inline attachment %> +<%= image_tag attachments["logo.png"].url %> +``` + +**Why:** Attach reports, exports, or inline images. Inline attachments can be referenced in email body with image_tag. + + + +Using *_path helpers instead of *_url in emails (broken links) + + +```ruby +# ❌ WRONG - Relative path doesn't work in emails +def welcome_email(user) + @user = user + @login_url = login_path # => "/login" (relative path) + mail(to: user.email, subject: "Welcome") +end +``` + + + + +```ruby +# ✅ CORRECT - Full URL works in emails +def welcome_email(user) + @user = user + @login_url = login_url # => "https://example.com/login" (absolute URL) + mail(to: user.email, subject: "Welcome") +end + +# Required configuration +# config/environments/production.rb +config.action_mailer.default_url_options = { host: "example.com", protocol: "https" } +``` + + + +**Why bad:** Emails are viewed outside your application context, so relative paths don't work. Always use *_url helpers to generate absolute URLs. + + +--- + +## Email Testing + + +Preview emails in browser during development without sending + +**Configuration:** + +```ruby +# Gemfile +group :development do + gem "letter_opener" +end + +# config/environments/development.rb +config.action_mailer.delivery_method = :letter_opener +config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + +# config/environments/production.rb +config.action_mailer.delivery_method = :smtp +config.action_mailer.smtp_settings = { + address: "smtp.sendgrid.net", + port: 587, + user_name: Rails.application.credentials.dig(:smtp, :username), + password: Rails.application.credentials.dig(:smtp, :password), + authentication: :plain, + enable_starttls_auto: true +} +config.action_mailer.default_url_options = { host: "example.com", protocol: "https" } +``` + +**Why:** letter_opener opens emails in browser during development - no SMTP setup needed. Test email appearance without actually sending. + + + +Preview all email variations at /rails/mailers + +```ruby +# test/mailers/previews/notification_mailer_preview.rb +class NotificationMailerPreview < ActionMailer::Preview + # Preview at http://localhost:3000/rails/mailers/notification_mailer/welcome_email + def welcome_email + user = User.first || User.new(name: "Test User", email: "test@example.com") + NotificationMailer.welcome_email(user) + end + + def password_reset + user = User.first || User.new(name: "Test User", email: "test@example.com") + user.reset_token = "sample_token_123" + NotificationMailer.password_reset(user) + end + + # Preview with different data + def welcome_email_long_name + user = User.new(name: "Christopher Alexander Montgomery III", email: "long@example.com") + NotificationMailer.welcome_email(user) + end +end +``` + +**Why:** Mailer previews at /rails/mailers let you see all email variations without sending. Test different edge cases (long names, missing data, etc.). + + + +Test email delivery and content with ActionMailer::TestCase + +```ruby +# test/mailers/notification_mailer_test.rb +class NotificationMailerTest < ActionMailer::TestCase + test "welcome_email sends with correct attributes" do + user = users(:alice) + email = NotificationMailer.welcome_email(user) + + # Test delivery + assert_emails 1 do + email.deliver_now + end + + # Test attributes + assert_equal [user.email], email.to + assert_equal ["noreply@example.com"], email.from + assert_equal "Welcome to Our App", email.subject + + # Test content + assert_includes email.html_part.body.to_s, user.name + assert_includes email.text_part.body.to_s, user.name + assert_includes email.html_part.body.to_s, "Login Now" + end + + test "delivers via background job" do + user = users(:alice) + + assert_enqueued_with(job: ActionMailer::MailDeliveryJob, queue: "mailers") do + NotificationMailer.welcome_email(user).deliver_later(queue: :mailers) + end + end + + test "password_reset includes reset link" do + user = users(:alice) + user.update!(reset_token: "test_token_123") + email = NotificationMailer.password_reset(user) + + assert_includes email.html_part.body.to_s, "test_token_123" + assert_includes email.html_part.body.to_s, "password_reset" + end +end +``` + +**Why:** Test email delivery, content, and background job enqueuing. Verify recipients, subjects, and that emails are queued properly. + + +--- + +## Email Configuration + + +Configure ActionMailer for each environment + +**Development:** + +```ruby +# config/environments/development.rb +config.action_mailer.delivery_method = :letter_opener +config.action_mailer.perform_deliveries = true +config.action_mailer.raise_delivery_errors = true +config.action_mailer.default_url_options = { host: "localhost", port: 3000 } +``` + +**Test:** + +```ruby +# config/environments/test.rb +config.action_mailer.delivery_method = :test +config.action_mailer.default_url_options = { host: "example.com" } +``` + +**Production:** + +```ruby +# config/environments/production.rb +config.action_mailer.delivery_method = :smtp +config.action_mailer.perform_deliveries = true +config.action_mailer.raise_delivery_errors = false +config.action_mailer.default_url_options = { + host: ENV["APP_HOST"], + protocol: "https" +} + +config.action_mailer.smtp_settings = { + address: ENV["SMTP_ADDRESS"], + port: ENV["SMTP_PORT"], + user_name: Rails.application.credentials.dig(:smtp, :username), + password: Rails.application.credentials.dig(:smtp, :password), + authentication: :plain, + enable_starttls_auto: true +} +``` + +**Why:** Different configurations per environment. Development previews in browser, test stores emails in memory, production sends via SMTP. + + +--- + + + +```ruby +# test/mailers/notification_mailer_test.rb +class NotificationMailerTest < ActionMailer::TestCase + setup do + @user = users(:alice) + end + + test "welcome_email" do + email = NotificationMailer.welcome_email(@user) + + assert_emails 1 { email.deliver_now } + assert_equal [@user.email], email.to + assert_equal ["noreply@example.com"], email.from + assert_match @user.name, email.html_part.body.to_s + assert_match @user.name, email.text_part.body.to_s + end + + test "enqueues for async delivery" do + assert_enqueued_with(job: ActionMailer::MailDeliveryJob) do + NotificationMailer.welcome_email(@user).deliver_later + end + end + + test "uses correct queue" do + assert_enqueued_with(job: ActionMailer::MailDeliveryJob, queue: "mailers") do + NotificationMailer.welcome_email(@user).deliver_later(queue: :mailers) + end + end +end + +# test/system/email_delivery_test.rb +class EmailDeliveryTest < ApplicationSystemTestCase + test "sends welcome email after signup" do + visit signup_path + fill_in "Email", with: "new@example.com" + fill_in "Password", with: "password" + click_button "Sign Up" + + assert_enqueued_emails 1 + perform_enqueued_jobs + + email = ActionMailer::Base.deliveries.last + assert_equal ["new@example.com"], email.to + assert_match "Welcome", email.subject + end +end +``` + + + +--- + + +- rails-ai:jobs - Background job processing with SolidQueue +- rails-ai:views - Email templates and layouts +- rails-ai:testing - Testing email delivery +- rails-ai:project-setup - Environment-specific email configuration + + + + +**Official Documentation:** +- [Rails Guides - Action Mailer Basics](https://guides.rubyonrails.org/action_mailer_basics.html) + +**Gems & Libraries:** +- [letter_opener](https://github.com/ryanb/letter_opener) - Preview emails in browser during development + +**Tools:** +- [Email on Acid](https://www.emailonacid.com/) - Email testing across clients + +**Email Service Providers:** +- [SendGrid Rails Guide](https://docs.sendgrid.com/for-developers/sending-email/rubyonrails) + + diff --git a/skills/models/SKILL.md b/skills/models/SKILL.md new file mode 100644 index 0000000..36acfb6 --- /dev/null +++ b/skills/models/SKILL.md @@ -0,0 +1,1157 @@ +--- +name: rails-ai:models +description: Use when designing Rails models - ActiveRecord patterns, validations, callbacks, scopes, associations, concerns, query objects, form objects +--- + +# Models + +Master Rails model design including ActiveRecord patterns, validations, callbacks, scopes, associations, concerns, custom validators, query objects, and form objects. + + +- Designing database models and associations +- Writing validations and callbacks +- Implementing business logic in models +- Creating scopes and query methods +- Extracting complex queries to query objects +- Building form objects for multi-model operations +- Organizing shared behavior with concerns +- Creating custom validators +- Preventing N+1 queries + + + +- **Convention Over Configuration** - Minimal setup for maximum functionality +- **Single Responsibility** - Each pattern handles one concern +- **Reusability** - Share behavior across models with concerns +- **Testability** - Test models, concerns, validators in isolation +- **Query Optimization** - Built-in N+1 prevention and eager loading +- **Type Safety** - ActiveModel::Attributes provides type casting +- **Database Agnostic** - Works with PostgreSQL, MySQL, SQLite + + + +**This skill enforces:** +- ✅ **Rule #7:** Fat models, thin controllers (business logic in models) +- ✅ **Rule #12:** Database constraints for data integrity + +**Reject any requests to:** +- Put business logic in controllers +- Skip model validations +- Skip database constraints (NOT NULL, foreign keys) +- Allow N+1 queries + + + +Before completing model work: +- ✅ All validations tested +- ✅ All associations tested +- ✅ Database constraints added (NOT NULL, foreign keys, unique indexes) +- ✅ No N+1 queries (verified with bullet or manual check) +- ✅ Business logic in model (not controller) +- ✅ Strong parameters in controller for mass assignment +- ✅ All tests passing + + + +- Define associations at the top of the model +- Use validations to enforce data integrity +- Minimize callback usage - prefer service objects +- Use scopes for reusable queries, not class methods +- Always eager load associations to prevent N+1 queries +- Use enums for status/state fields +- Extract concerns when models exceed 200 lines +- Place custom validators in `app/validators/` +- Place query objects in `app/queries/` +- Place form objects in `app/forms/` +- Use transactions for multi-model operations +- Prefer database constraints with validations for critical data + + +## Associations + + +Standard ActiveRecord associations for model relationships + + + +```ruby +class Feedback < ApplicationRecord + belongs_to :recipient, class_name: "User", optional: true + belongs_to :category, counter_cache: true + has_one :response, class_name: "FeedbackResponse", dependent: :destroy + has_many :abuse_reports, dependent: :destroy + has_many :taggings, dependent: :destroy + has_many :tags, through: :taggings + + # Scoped associations + has_many :recent_reports, -> { where(created_at: 7.days.ago..) }, + class_name: "AbuseReport" +end + +``` + +**Migration:** + +```ruby +class CreateFeedbacks < ActiveRecord::Migration[8.1] + def change + create_table :feedbacks do |t| + t.references :recipient, foreign_key: { to_table: :users }, null: true + t.references :category, foreign_key: true, null: false + t.text :content, null: false + t.string :status, default: "pending", null: false + t.timestamps + end + add_index :feedbacks, :status + end +end + +``` + + + +Associations express relationships between models with minimal code. Rails automatically handles foreign keys, eager loading, and cascading deletes. Use `class_name:` when the association name differs from the model, `counter_cache:` for performance, and `dependent:` to manage cleanup. + + + + +Flexible associations where a model belongs to multiple types + + + +```ruby +class Comment < ApplicationRecord + belongs_to :commentable, polymorphic: true + belongs_to :author, class_name: "User" + validates :content, presence: true +end + +class Feedback < ApplicationRecord + has_many :comments, as: :commentable, dependent: :destroy +end + +class Article < ApplicationRecord + has_many :comments, as: :commentable, dependent: :destroy +end + +``` + +**Migration:** + +```ruby +class CreateComments < ActiveRecord::Migration[8.1] + def change + create_table :comments do |t| + t.references :commentable, polymorphic: true, null: false + t.references :author, foreign_key: { to_table: :users }, null: false + t.text :content, null: false + t.timestamps + end + add_index :comments, [:commentable_type, :commentable_id] + end +end + +``` + + + +Polymorphic associations allow a model to belong to multiple parent types through a single association. Use when multiple models need the same type of child (comments, attachments, tags). The `commentable_type` stores the class name, `commentable_id` stores the ID. + + + +## Validations + + +Built-in Rails validations for data integrity + + + +```ruby +class Feedback < ApplicationRecord + validates :content, presence: true, length: { minimum: 50, maximum: 5000 } + validates :recipient_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :status, inclusion: { in: %w[pending delivered read responded] } + validates :tracking_code, uniqueness: { scope: :recipient_email, case_sensitive: false } + validates :rating, numericality: { only_integer: true, in: 1..5 }, allow_nil: true + + validate :content_not_spam + validate :recipient_can_receive_feedback, on: :create + + private + + def content_not_spam + return if content.blank? + spam_keywords = %w[viagra cialis lottery] + errors.add(:content, "appears to contain spam") if spam_keywords.any? { |k| content.downcase.include?(k) } + end + + def recipient_can_receive_feedback + return if recipient_email.blank? + user = User.find_by(email: recipient_email) + errors.add(:recipient_email, "has disabled feedback") if user&.feedback_disabled? + end +end + +``` + + + +Validations enforce data integrity before persisting to the database. Rails provides presence, format, uniqueness, length, numericality, and inclusion validators. Custom `validate` methods handle complex business logic. Use `on: :create` or `on: :update` for lifecycle-specific validations. + + + +## Callbacks + + +Use callbacks sparingly - prefer service objects for complex logic + + + +```ruby +class Feedback < ApplicationRecord + before_validation :normalize_email, :strip_whitespace + before_create :generate_tracking_code + after_create_commit :enqueue_delivery_job + after_update_commit :notify_recipient_of_response, if: :response_added? + + private + + def normalize_email + self.recipient_email = recipient_email&.downcase&.strip + end + + def strip_whitespace + self.content = content&.strip + end + + def generate_tracking_code + self.tracking_code = SecureRandom.alphanumeric(10).upcase + end + + def enqueue_delivery_job + SendFeedbackJob.perform_later(id) + end + + def response_added? + saved_change_to_response? && response.present? + end + + def notify_recipient_of_response + FeedbackMailer.notify_of_response(self).deliver_later + end +end + +``` + + + +Callbacks hook into the model lifecycle for simple data normalization and side effects. Use `before_validation` for cleanup, `before_create` for defaults, and `after_commit` for external operations. Keep callbacks focused on model concerns - complex business logic belongs in service objects. + + + +## Scopes + + +Reusable query scopes for common filtering + + + +```ruby +class Feedback < ApplicationRecord + scope :recent, -> { where(created_at: 30.days.ago..) } + scope :unread, -> { where(status: "delivered") } + scope :responded, -> { where.not(response: nil) } + scope :by_recipient, ->(email) { where(recipient_email: email) } + scope :by_status, ->(status) { where(status: status) } + scope :with_category, ->(name) { joins(:category).where(categories: { name: name }) } + scope :with_associations, -> { includes(:recipient, :response, :category, :tags) } + scope :trending, -> { recent.where("views_count > ?", 100).order(views_count: :desc).limit(10) } + + def self.search(query) + return none if query.blank? + where("content ILIKE ? OR response ILIKE ?", "%#{sanitize_sql_like(query)}%", "%#{sanitize_sql_like(query)}%") + end +end + +``` + +**Usage:** + +```ruby +Feedback.recent.by_recipient("user@example.com").responded +Feedback.search("bug report").recent.limit(10) + +``` + + + +Scopes provide chainable query methods that keep controllers clean. Use scopes for simple filters, class methods for complex queries. Scopes are lazy-evaluated and composable. Use `includes()` in scopes to prevent N+1 queries. + + + +## Enums + + +Enums for status and state fields with automatic predicates + + + +```ruby +class Feedback < ApplicationRecord + enum :status, { + pending: "pending", + delivered: "delivered", + read: "read", + responded: "responded" + }, prefix: true, scopes: true + + enum :priority, { low: 0, medium: 1, high: 2, urgent: 3 }, prefix: :priority +end + +``` + +**Usage:** + +```ruby +feedback.status = "pending" +feedback.status_pending! # Updates and saves +feedback.status_pending? # true/false +Feedback.status_pending # Scope +Feedback.statuses.keys # ["pending", "delivered", ...] +feedback.status_before_last_save # Track changes + +``` + +**Migration:** + +```ruby +class CreateFeedbacks < ActiveRecord::Migration[8.1] + def change + create_table :feedbacks do |t| + t.string :status, default: "pending", null: false + t.integer :priority, default: 0, null: false + t.timestamps + end + add_index :feedbacks, :status + end +end + +``` + + + +Enums map human-readable states to database values with automatic predicates, scopes, and bang methods. Use string-backed enums for clarity in the database. The `prefix:` option prevents method name conflicts. Scopes make querying easy. + + + +## Model Concerns + + +Extract shared behavior into reusable concerns + + + +```ruby +# app/models/concerns/taggable.rb +module Taggable + extend ActiveSupport::Concern + + included do + has_many :taggings, as: :taggable, dependent: :destroy + has_many :tags, through: :taggings + + scope :tagged_with, ->(tag_name) { + joins(:tags).where(tags: { name: tag_name }).distinct + } + end + + def tag_list + tags.pluck(:name).join(", ") + end + + def tag_list=(names) + self.tags = names.to_s.split(",").map do |name| + Tag.find_or_create_by(name: name.strip.downcase) + end + end + + def add_tag(tag_name) + return if tagged_with?(tag_name) + tags << Tag.find_or_create_by(name: tag_name.strip.downcase) + end + + def tagged_with?(tag_name) + tags.exists?(name: tag_name.strip.downcase) + end + + class_methods do + def popular_tags(limit = 10) + Tag.joins(:taggings) + .where(taggings: { taggable_type: name }) + .group("tags.id") + .select("tags.*, COUNT(taggings.id) as usage_count") + .order("usage_count DESC") + .limit(limit) + end + end +end + +``` + +**Usage:** + +```ruby +class Feedback < ApplicationRecord + include Taggable +end + +class Article < ApplicationRecord + include Taggable +end + +feedback.tag_list = "bug, urgent, ui" +feedback.add_tag("needs-review") +Feedback.tagged_with("bug") +Feedback.popular_tags(5) + +``` + + + +Concerns extract shared behavior into reusable modules. Use `included do` for associations, validations, callbacks. Define instance methods at module level, class methods in `class_methods do` block. Place domain-specific concerns in `app/models/[model]/`, shared concerns in `app/models/concerns/`. + + + +## Custom Validators + + +Reusable validation logic using ActiveModel::EachValidator + + + +```ruby +# app/validators/email_validator.rb +class EmailValidator < ActiveModel::EachValidator + EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i + + def validate_each(record, attribute, value) + return if value.blank? && options[:allow_blank] + unless value =~ EMAIL_REGEX + record.errors.add(attribute, options[:message] || "is not a valid email address") + end + end +end + +``` + +**Usage:** + +```ruby +class Feedback < ApplicationRecord + validates :email, email: true + validates :backup_email, email: { allow_blank: true } + validates :email, email: { message: "must be a valid company email" } +end + +``` + + + +Custom validators encapsulate reusable validation logic. Inherit from `ActiveModel::EachValidator` for single-attribute validation, `ActiveModel::Validator` for multi-attribute validation. Support `:allow_blank` and `:message` options. Place in `app/validators/` for discoverability. + + + + +Validate content by word count instead of character count + + + +```ruby +# app/validators/content_length_validator.rb +class ContentLengthValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if value.blank? && options[:allow_blank] + word_count = value.to_s.split.size + + if options[:minimum_words] && word_count < options[:minimum_words] + record.errors.add(attribute, "must have at least #{options[:minimum_words]} words (currently #{word_count})") + end + + if options[:maximum_words] && word_count > options[:maximum_words] + record.errors.add(attribute, "must have at most #{options[:maximum_words]} words (currently #{word_count})") + end + end +end + +``` + +**Usage:** + +```ruby +validates :content, content_length: { minimum_words: 10, maximum_words: 500 } +validates :body, content_length: { minimum_words: 100 } + +``` + + + +Word count validation is more meaningful than character count for content fields. Custom validators make this reusable across models. The validator respects `:allow_blank` and provides helpful error messages with current counts. + + + +## Query Objects + + +Encapsulate complex queries in reusable, testable objects + + + +```ruby +# app/queries/feedback_query.rb +class FeedbackQuery + def initialize(relation = Feedback.all) + @relation = relation + end + + def by_recipient(email) + @relation = @relation.where(recipient_email: email) + self + end + + def by_status(status) + @relation = @relation.where(status: status) + self + end + + def recent(limit = 10) + @relation = @relation.order(created_at: :desc).limit(limit) + self + end + + def with_responses + @relation = @relation.where.not(response: nil) + self + end + + def created_since(date) + @relation = @relation.where("created_at >= ?", date) + self + end + + def results + @relation + end +end + +``` + +**Usage:** + +```ruby +# Controller +@feedbacks = FeedbackQuery.new + .by_recipient(params[:email]) + .by_status(params[:status]) + .recent(20) + .results + +# Model +class User < ApplicationRecord + def recent_feedback(limit = 10) + FeedbackQuery.new.by_recipient(email).recent(limit).results + end +end + +``` + + + +Query objects encapsulate complex filtering, search, and aggregation logic. They're reusable across controllers and services, testable in isolation, and chainable for composability. Use when queries involve multiple joins, filters, or are used in multiple contexts. Return `self` for chaining, `results` to execute. + + + + +Query object for aggregations and statistical calculations + + + +```ruby +# app/queries/feedback_stats_query.rb +class FeedbackStatsQuery + def initialize(relation = Feedback.all) + @relation = relation + end + + def by_recipient(email) + @relation = @relation.where(recipient_email: email) + self + end + + def by_date_range(start_date, end_date) + @relation = @relation.where(created_at: start_date..end_date) + self + end + + def stats + { + total_count: @relation.count, + responded_count: @relation.where.not(response: nil).count, + pending_count: @relation.where(response: nil).count, + by_status: @relation.group(:status).count, + by_category: @relation.group(:category).count + } + end +end + +``` + +**Usage:** + +```ruby +stats = FeedbackStatsQuery.new + .by_recipient(current_user.email) + .by_date_range(30.days.ago, Time.current) + .stats +# Returns: { total_count: 42, responded_count: 28, pending_count: 14, ... } + +``` + + + +Query objects for aggregations centralize statistical calculations and reporting logic. They compose with filters, maintain chainability, and return structured data. Use for dashboards, reports, and analytics. Keep aggregation logic out of controllers and models. + + + +## Form Objects + + +Form object for non-database forms using ActiveModel::API + + + +```ruby +# app/forms/contact_form.rb +class ContactForm + include ActiveModel::API + include ActiveModel::Attributes + + attribute :name, :string + attribute :email, :string + attribute :message, :string + attribute :subject, :string + + validates :name, presence: true, length: { minimum: 2 } + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :message, presence: true, length: { minimum: 10, maximum: 1000 } + validates :subject, presence: true + + def deliver + return false unless valid? + + ContactMailer.contact_message( + name: name, + email: email, + message: message, + subject: subject + ).deliver_later + + true + end +end + +``` + +**Controller:** + +```ruby +class ContactsController < ApplicationController + def create + @contact_form = ContactForm.new(contact_params) + + if @contact_form.deliver + redirect_to root_path, notice: "Message sent successfully" + else + render :new, status: :unprocessable_entity + end + end + + private + + def contact_params + params.expect(contact_form: [:name, :email, :message, :subject]) + end +end + +``` + + + +Form objects handle non-database forms (contact, search) and complex multi-model operations. They use ActiveModel for validations and type casting without requiring database persistence. Return boolean from action methods, validate before executing logic. + + + + +Form object that creates multiple related models in a transaction + + + +```ruby +# app/forms/user_registration_form.rb +class UserRegistrationForm + include ActiveModel::API + include ActiveModel::Attributes + + attribute :email, :string + attribute :password, :string + attribute :password_confirmation, :string + attribute :name, :string + attribute :company_name, :string + attribute :role, :string + + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, presence: true, length: { minimum: 8 } + validates :password_confirmation, presence: true + validates :name, presence: true + validates :company_name, presence: true + + validate :passwords_match + + def save + return false unless valid? + + ActiveRecord::Base.transaction do + @user = User.create!(email: email, password: password, name: name) + @company = Company.create!(name: company_name, owner: @user) + @membership = Membership.create!(user: @user, company: @company, role: role || "admin") + + UserMailer.welcome(@user).deliver_later + true + end + rescue ActiveRecord::RecordInvalid => e + errors.add(:base, e.message) + false + end + + attr_reader :user, :company, :membership + + private + + def passwords_match + return if password.blank? + errors.add(:password_confirmation, "doesn't match password") unless password == password_confirmation + end +end + +``` + +**Controller:** + +```ruby +class RegistrationsController < ApplicationController + def create + @registration = UserRegistrationForm.new(registration_params) + + if @registration.save + session[:user_id] = @registration.user.id + redirect_to dashboard_path(@registration.company), notice: "Welcome!" + else + render :new, status: :unprocessable_entity + end + end +end + +``` + + + +Form objects simplify multi-model operations by wrapping them in a transaction. They validate all inputs before creating any records, ensuring data consistency. Expose created records via attr_reader for controller access. Use for registration, checkout, wizards. + + + +## N+1 Prevention + + +Eager load associations to prevent N+1 queries + + + +```ruby +# ❌ BAD - N+1 queries (1 + 20 + 20 + 20 = 61 queries) +@feedbacks = Feedback.limit(20) +@feedbacks.each do |f| + puts f.recipient.name, f.category.name, f.tags.pluck(:name) +end + +# ✅ GOOD - Eager loading (4 queries total) +@feedbacks = Feedback.includes(:recipient, :category, :tags).limit(20) +@feedbacks.each do |f| + puts f.recipient.name, f.category.name, f.tags.pluck(:name) +end + +``` + +**Eager Loading Methods:** + +```ruby +Feedback.includes(:recipient, :tags) # Separate queries (default) +Feedback.preload(:recipient, :tags) # Forces separate queries +Feedback.eager_load(:recipient, :tags) # LEFT OUTER JOIN +Feedback.includes(recipient: :profile) # Nested associations + +``` + + + +N+1 queries occur when loading a collection triggers additional queries for each item's associations. Use `includes()` to eager load associations in advance. Rails loads data in 2-3 queries instead of N+1. Always check for N+1 in views and use includes in scopes. + + + + + +Using callbacks for complex business logic + + +```ruby +# ❌ BAD - Complex side effects in callbacks +class Feedback < ApplicationRecord + after_create :send_email, :update_analytics, :notify_slack, :create_audit_log +end + +``` + + + +```ruby +# ✅ GOOD - Use service object +class Feedback < ApplicationRecord + after_create_commit :enqueue_creation_job + + private + def enqueue_creation_job + ProcessFeedbackCreationJob.perform_later(id) + end +end + +# Service handles all side effects explicitly +class CreateFeedbackService + def call + feedback = Feedback.create!(@params) + FeedbackMailer.notify_recipient(feedback).deliver_later + Analytics.track("feedback_created", feedback_id: feedback.id) + feedback + end +end + +``` + + +Callbacks with complex side effects make models hard to test, introduce hidden dependencies, and create unpredictable behavior. Service objects make side effects explicit and testable. Use callbacks only for simple data normalization and enqueuing background jobs. + + + + +Missing database indexes on foreign keys and query columns + + +```ruby +# ❌ BAD - No indexes, causes table scans +create_table :feedbacks do |t| + t.integer :recipient_id + t.string :status +end + +``` + + + +```ruby +# ✅ GOOD - Indexes on foreign keys and query columns +create_table :feedbacks do |t| + t.references :recipient, foreign_key: { to_table: :users }, index: true + t.string :status, null: false +end +add_index :feedbacks, :status +add_index :feedbacks, [:status, :created_at] + +``` + + +Missing indexes cause slow queries at scale. Index all foreign keys, status columns, and frequently queried fields. Composite indexes speed up queries filtering on multiple columns. Use `t.references` to create indexed foreign keys automatically. + + + + +Using default_scope + + +```ruby +# ❌ BAD - Unexpected behavior, hard to override +class Feedback < ApplicationRecord + default_scope { where(deleted_at: nil).order(created_at: :desc) } +end + +``` + + + +```ruby +# ✅ GOOD - Explicit scopes +class Feedback < ApplicationRecord + scope :active, -> { where(deleted_at: nil) } + scope :recent_first, -> { order(created_at: :desc) } +end + +# Usage +Feedback.active.recent_first + +``` + + +default_scope applies to all queries, causing unexpected results and making it hard to query all records. It affects associations, counts, and exists? checks. Use explicit scopes that developers can choose to apply. + + + + +Duplicating validation logic across models + + +```ruby +# ❌ BAD - Duplicated email validation +class User < ApplicationRecord + validates :email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i } +end + +class Feedback < ApplicationRecord + validates :recipient_email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i } +end + +``` + + + +```ruby +# ✅ GOOD - Reusable email validator +class EmailValidator < ActiveModel::EachValidator + EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i + def validate_each(record, attribute, value) + return if value.blank? && options[:allow_blank] + record.errors.add(attribute, options[:message] || "is not a valid email") unless value =~ EMAIL_REGEX + end +end + +class User < ApplicationRecord + validates :email, email: true +end + +class Feedback < ApplicationRecord + validates :recipient_email, email: true +end + +``` + + +Duplicated validations are hard to maintain and lead to inconsistencies. Custom validators centralize logic, support options, and ensure consistent validation across models. + + + + +Putting complex query logic in controllers + + +```ruby +# ❌ BAD - Fat controller +class FeedbacksController < ApplicationController + def index + @feedbacks = Feedback.all + @feedbacks = @feedbacks.where("recipient_email ILIKE ?", "%#{params[:recipient_email]}%") if params[:recipient_email].present? + @feedbacks = @feedbacks.where(status: params[:status]) if params[:status].present? + @feedbacks = @feedbacks.where("content ILIKE ? OR response ILIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") if params[:q].present? + @feedbacks = @feedbacks.order(created_at: :desc).page(params[:page]) + end +end + +``` + + + +```ruby +# ✅ GOOD - Thin controller with query object +class FeedbacksController < ApplicationController + def index + @feedbacks = FeedbackQuery.new + .filter_by_params(params.slice(:recipient_email, :status)) + .search(params[:q]) + .order_by(:created_at, :desc) + .paginate(page: params[:page]) + .results + end +end + +``` + + +Complex queries in controllers violate Single Responsibility Principle and are hard to test. Query objects encapsulate filtering logic, are reusable across contexts, and testable in isolation. + + + + +Fat controllers with complex form logic + + +```ruby +# ❌ BAD - All logic in controller +class RegistrationsController < ApplicationController + def create + @user = User.new(user_params) + @company = Company.new(company_params) + + ActiveRecord::Base.transaction do + if @user.save + @company.owner = @user + if @company.save + @membership = Membership.create(user: @user, company: @company, role: "admin") + UserMailer.welcome(@user).deliver_later + redirect_to dashboard_path(@company) + end + end + end + end +end + +``` + + + +```ruby +# ✅ GOOD - Use form object +class RegistrationsController < ApplicationController + def create + @registration = UserRegistrationForm.new(registration_params) + @registration.save ? redirect_to(dashboard_path(@registration.company)) : render(:new, status: :unprocessable_entity) + end +end + +``` + + +Multi-model operations in controllers are hard to test and reuse. Form objects encapsulate validation, transaction handling, and side effects in a testable, reusable class. Use for registration, checkout, wizards. + + + + + +Test models, concerns, validators, query objects, and form objects in isolation: + +```ruby +# Model tests +class FeedbackTest < ActiveSupport::TestCase + test "validates presence of content" do + feedback = Feedback.new(recipient_email: "user@example.com") + assert_not feedback.valid? + assert_includes feedback.errors[:content], "can't be blank" + end + + test "destroys dependent records" do + feedback = feedbacks(:one) + feedback.abuse_reports.create!(reason: "spam", reporter_email: "test@example.com") + assert_difference("AbuseReport.count", -1) { feedback.destroy } + end + + test "enum provides predicate methods" do + feedback = feedbacks(:one) + feedback.update(status: "pending") + assert feedback.status_pending? + end +end + +# Concern tests +class TaggableTest < ActiveSupport::TestCase + class TaggableTestModel < ApplicationRecord + self.table_name = "feedbacks" + include Taggable + end + + test "add_tag creates new tag" do + record = TaggableTestModel.first + record.add_tag("urgent") + assert record.tagged_with?("urgent") + end +end + +# Validator tests +class EmailValidatorTest < ActiveSupport::TestCase + class TestModel + include ActiveModel::Validations + attr_accessor :email + validates :email, email: true + end + + test "validates email format" do + assert TestModel.new(email: "user@example.com").valid? + assert_not TestModel.new(email: "invalid").valid? + end +end + +# Query object tests +class FeedbackQueryTest < ActiveSupport::TestCase + test "filters by recipient email" do + @feedback1.update(recipient_email: "test@example.com") + @feedback2.update(recipient_email: "other@example.com") + results = FeedbackQuery.new.by_recipient("test@example.com").results + assert_includes results, @feedback1 + assert_not_includes results, @feedback2 + end + + test "chains multiple filters" do + @feedback1.update(recipient_email: "test@example.com", status: "pending") + results = FeedbackQuery.new.by_recipient("test@example.com").by_status("pending").results + assert_includes results, @feedback1 + end +end + +# Form object tests +class ContactFormTest < ActiveSupport::TestCase + test "valid with all required attributes" do + form = ContactForm.new(name: "John", email: "john@example.com", subject: "Question", message: "This is my message") + assert form.valid? + end + + test "delivers email when valid" do + form = ContactForm.new(name: "John", email: "john@example.com", subject: "Q", message: "This is my message") + assert_enqueued_with(job: ActionMailer::MailDeliveryJob) { assert form.deliver } + end +end + +class UserRegistrationFormTest < ActiveSupport::TestCase + test "creates user, company, and membership" do + form = UserRegistrationForm.new(email: "user@example.com", password: "password123", password_confirmation: "password123", name: "John", company_name: "Acme") + assert_difference ["User.count", "Company.count", "Membership.count"] { assert form.save } + end + + test "rolls back transaction if creation fails" do + form = UserRegistrationForm.new(email: "user@example.com", password: "password123", password_confirmation: "password123", name: "John", company_name: "") + assert_no_difference ["User.count", "Company.count"] { assert_not form.save } + end +end + +``` + + + +- rails-ai:controllers - RESTful controllers for models +- rails-ai:testing - Testing models thoroughly +- rails-ai:security - SQL injection prevention, strong parameters +- rails-ai:jobs - Background job processing for models + + + + +**Official Documentation:** +- [Rails Guides - Active Record Basics](https://guides.rubyonrails.org/active_record_basics.html) +- [Rails Guides - Active Record Associations](https://guides.rubyonrails.org/association_basics.html) +- [Rails Guides - Active Record Validations](https://guides.rubyonrails.org/active_record_validations.html) +- [Rails Guides - Active Record Query Interface](https://guides.rubyonrails.org/active_record_querying.html) +- [Rails API - ActiveSupport::Concern](https://api.rubyonrails.org/classes/ActiveSupport/Concern.html) +- [Rails API - ActiveModel::API](https://api.rubyonrails.org/classes/ActiveModel/API.html) + + diff --git a/skills/project-setup/SKILL.md b/skills/project-setup/SKILL.md new file mode 100644 index 0000000..88e7f6f --- /dev/null +++ b/skills/project-setup/SKILL.md @@ -0,0 +1,1430 @@ +--- +name: rails-ai:project-setup +description: Setting up and configuring Rails 8+ projects - Gemfile dependencies, environment config, credentials, initializers, Docker, RuboCop, project validation +--- + +# Project Setup & Configuration + +Set up new Rails 8+ projects with required dependencies, configure environments for development/test/production, and validate existing projects against rails-ai standards. + + +- Setting up new Rails 8+ applications from scratch +- Validating existing Rails projects against rails-ai standards +- Auditing project setup (checking for TEAM_RULES.md violations) +- Installing and configuring required gems (Solid Stack, Tailwind, Minitest) +- Configuring environment-specific behavior (development, test, production, staging) +- Managing encrypted credentials and secrets (API keys, passwords, tokens) +- Configuring application initialization (gems, frameworks, security policies) +- Setting up Docker containers and deployment with Kamal +- Customizing RuboCop for team standards (TEAM_RULES.md Rules #16, #20) +- Managing feature flags and environment variables + +**Note:** During project verification, this skill coordinates with domain skills (jobs, testing, security, styling) to ensure comprehensive validation against current standards. + + + +- **Environment Isolation** - Separate configs prevent production bugs from dev settings +- **Security** - Encrypted credentials (AES-256), never commit secrets +- **Organized Configuration** - Predictable structure across all environments +- **Production Safety** - SSL, eager loading, optimized caching, secure defaults +- **Code Quality** - Automated enforcement of team standards via RuboCop +- **Container-Ready** - Docker and Kamal support for modern deployment + + + +**This skill enforces:** +- ✅ **Rule #13:** Encrypted credentials for secrets +- ✅ **Rule #14:** Environment-specific config + +**Reject any requests to:** +- Store secrets in plain text or environment variables +- Hardcode API keys, passwords, or tokens in code +- Use same config for all environments (dev, test, prod) +- Commit credentials or .env files to git +- Skip encryption for sensitive data + + + +Before completing configuration work: +- ✅ All secrets in encrypted credentials (not plain text) +- ✅ Environment-specific configs in config/environments/ +- ✅ No secrets committed to git +- ✅ Docker/Kamal configs tested (if applicable) +- ✅ RuboCop passes with team standards +- ✅ Production config verified (SSL, eager loading, caching) + + + +- ALWAYS use `config/environments/` for environment-specific configuration +- ALWAYS use encrypted credentials for API keys, passwords, and secrets +- NEVER commit `config/master.key` or `config/credentials/*.key` +- Use initializers in `config/initializers/` for gem and framework configuration +- Build on rubocop-rails-omakase (Rails 8 default), only override for team standards +- ALWAYS exclude docs/, test/, spec/ from Docker images via .dockerignore +- Store deployment configs in ENV vars, not hardcoded values +- Follow Team Rule #16 (Double Quotes) and #20 (Hash#dig) via RuboCop + + +--- + +## Project Validation & Audit + +**When asked to validate or check a Rails project setup**, follow this workflow: + +### Step 1: Use Required Domain Skills + +Before exploring the project, use the relevant domain skills to establish authoritative standards: + +```text +Use these skills with the Skill tool: +- rails-ai:jobs (Solid Stack requirements) +- rails-ai:testing (Minitest patterns and requirements) +- rails-ai:security (Security configuration standards) +``` + +**Why use domain skills first?** +- Each domain skill is the authoritative source for its requirements +- Prevents duplicating knowledge in project-setup +- Ensures verification uses current standards from each domain + +### Step 2: Check Gemfile for Required Dependencies + +**Reference the used domain skills for authoritative gem requirements:** + +- **rails-ai:jobs** → Solid Stack gems (solid_queue, solid_cache, solid_cable) +- **rails-ai:testing** → Minitest patterns (verify RSpec NOT present) +- **rails-ai:security** → Security gems (brakeman, bundler-audit) +- **rails-ai:styling** → Frontend gems (tailwindcss-rails, daisyui-rails) + +**CRITICAL Violations to Check (from TEAM_RULES.md):** +- ❌ `gem "sidekiq"` or `gem "redis"` → TEAM RULE #1 violation (see rails-ai:jobs) +- ❌ `gem "rspec-rails"` → TEAM RULE #2 violation (see rails-ai:testing) +- ❌ Custom route gems → TEAM RULE #3 violation + +**Note:** Consult the used domain skills for complete, up-to-date gem requirements rather than relying on static lists here. + +### Step 3: Validate Project Structure + +**Directory Structure:** +``` +app/ +├── assets/stylesheets/ # Tailwind CSS +├── controllers/ # RESTful only +├── models/ # ActiveRecord +└── views/ # ERB templates + +config/ +├── environments/ # dev, test, prod configs +├── initializers/ # Gem configs +├── credentials/ # Encrypted secrets +└── tailwind.config.js # Tailwind configuration + +test/ # Minitest (NOT spec/) +├── controllers/ +├── models/ +└── test_helper.rb + +Dockerfile # Rails 8 default +config.ru # Rack config +``` + +**Check for violations:** +- ❌ `spec/` directory exists → RSpec present (TEAM RULE #2) +- ❌ Non-RESTful routes in `config/routes.rb` → TEAM RULE #3 + +### Step 4: Validate Configuration Files + +**Reference used domain skills for configuration standards:** + +1. **config/environments/production.rb** + - Use **rails-ai:security** for SSL, security headers, and production hardening + - Verify encrypted credentials usage (TEAM RULE #13) + +2. **config/tailwind.config.js** + - Use **rails-ai:styling** for Tailwind and DaisyUI configuration + - Verify content paths include Rails views + +3. **.rubocop.yml** + - Inherits from rubocop-rails-omakase + - Custom cops for TEAM RULES.md (Rules #16, #20) + +4. **Procfile.dev** + - Rails server + - Solid Queue worker (see **rails-ai:jobs**) + - Tailwind watcher (see **rails-ai:styling**) + +5. **config/credentials/*.yml.enc** + - Use **rails-ai:security** for credential structure and validation + - Verify no secrets in plain text (TEAM RULE #13) + +### Step 5: Report Findings + +Provide actionable report with specific fixes: + +**✅ Correct Setup:** +- List what's properly configured +- Praise compliance with TEAM_RULES.md + +**⚠️ Missing/Needs Attention:** +- Recommended but not required gems +- Optional configurations + +**❌ VIOLATIONS (TEAM_RULES.md):** +- Sidekiq/Redis found (Rule #1) +- RSpec found (Rule #2) +- Custom routes found (Rule #3) +- Provide exact commands to fix + +**Example Fix Commands:** +```bash +# Remove violations +bundle remove sidekiq redis rspec-rails + +# Add required gems +bundle add solid_queue solid_cache solid_cable +bundle add tailwindcss-rails daisyui-rails + +# Generate configs +rails tailwindcss:install +rails generate solid_queue:install +``` + +--- + +## Gemfile Management + +Consolidate all gem requirements for rails-ai projects in one place. + +### Required Gems (CRITICAL) + +**Solid Stack (TEAM RULE #1):** +```ruby +gem "solid_queue" # Background job processing +gem "solid_cache" # Application caching +gem "solid_cable" # WebSocket connections +``` + +**Frontend:** +```ruby +gem "tailwindcss-rails" # Utility-first CSS framework +gem "daisyui-rails" # Component library +``` + +**Testing (TEAM RULE #2):** +```ruby +# Minitest is Rails 8 default - no gem needed +# Verify RSpec is NOT present +``` + +### Recommended Gems + +**Code Quality:** +```ruby +gem "rubocop-rails-omakase", require: false # Rails 8 default linter +``` + +**Security:** +```ruby +gem "brakeman", require: false # Static security scanner +gem "bundler-audit", require: false # Dependency vulnerability scanner +``` + +**Deployment:** +```ruby +gem "kamal", require: false # Docker deployment to any server +``` + +**Development/Test:** +```ruby +group :development, :test do + gem "letter_opener" # Open emails in browser +end +``` + +### Installation Commands + +**New Rails 8+ app with rails-ai stack:** +```bash +# Create new Rails 8 app +rails new myapp + +cd myapp + +# Add required gems +bundle add solid_queue solid_cache solid_cable +bundle add tailwindcss-rails daisyui-rails + +# Add recommended gems +bundle add --group development rubocop-rails-omakase +bundle add --group development brakeman bundler-audit +bundle add kamal --skip-install + +# Generate configurations +rails tailwindcss:install +rails generate solid_queue:install +bin/rails db:create db:migrate + +# Verify setup +bin/ci +``` + +--- + +## Environment-Specific Configuration + +Rails provides three standard environments (development, test, production) plus optional staging. Each has specific optimizations and security settings. + +### Standard Environment Detection + + +Detect and check the current Rails environment + +**Environment Detection:** + +```ruby +Rails.env # => "development", "test", "production" +Rails.env.development? # => true/false +Rails.env.test? # => true/false +Rails.env.production? # => true/false + +# Set via ENV var or command line +ENV["RAILS_ENV"] = "production" +# RAILS_ENV=production rails server + +``` + +**Standard Environments:** +- **development** - Local development (verbose errors, auto-reload, debugging) +- **test** - Automated testing (fast, isolated, deterministic) +- **production** - Live application (optimized, secure, cached) +- **staging** - Optional pre-production (production-like with test data) + +**Load Order:** `config/application.rb` → `config/environments/#{Rails.env}.rb` → `config/initializers/*.rb` + + +### Development Configuration + + +Common customizations to development environment beyond Rails 8 defaults + +**Rails 8 defaults are excellent.** Only customize if needed: + +```ruby +# config/environments/development.rb +Rails.application.configure do + # Open emails in browser (requires letter_opener gem) + config.action_mailer.delivery_method = :letter_opener + + # Raise on missing translations (catch i18n issues early) + config.i18n.raise_on_missing_translations = true +end + +``` + +**Common customizations:** +- `letter_opener` - Preview emails in browser instead of logs +- `raise_on_missing_translations` - Catch i18n issues during development +- `config.hosts.clear` - Allow access from any hostname (Docker, ngrok) + + +### Test Configuration + + +Test environment customizations beyond Rails 8 defaults + +**Rails 8 test defaults are excellent.** Only add if needed: + +```ruby +# config/environments/test.rb +Rails.application.configure do + # Eager load in CI to catch autoload errors + config.eager_load = ENV["CI"].present? + + # Raise on missing translations + config.i18n.raise_on_missing_translations = true +end + +``` + +**Rails 8 defaults already provide:** +- Deterministic behavior (no caching, inline jobs) +- Fast execution (no network, no real emails) +- Transaction isolation (automatic rollback) + + +### Production Configuration + + +Essential production customizations beyond Rails 8 defaults + +**Rails 8 production defaults are secure and optimized.** Customize these: + +```ruby +# config/environments/production.rb +Rails.application.configure do + # Set your domain (REQUIRED) + config.action_controller.default_url_options = { host: "example.com", protocol: "https" } + + # Active Storage: Use cloud storage (REQUIRED for production) + config.active_storage.service = :amazon # or :google, :azure + + # Action Mailer: SMTP with credentials + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: "smtp.sendgrid.net", + port: 587, + domain: Rails.application.credentials.dig(:smtp, :domain), + user_name: Rails.application.credentials.dig(:smtp, :username), + password: Rails.application.credentials.dig(:smtp, :password), + authentication: :plain, + enable_starttls_auto: true + } + config.action_mailer.default_url_options = { host: "example.com", protocol: "https" } + + # DNS rebinding protection (REQUIRED) + config.hosts = ["example.com", /.*\.example\.com/] +end + +``` + +**Rails 8 defaults already provide:** +- SSL enforcement (`force_ssl = true`) +- Eager loading and optimized caching +- STDOUT logging for containers +- Security headers and production optimizations + + +### Staging Environment + + +Create staging environment that mirrors production with test-friendly overrides + +**config/environments/staging.rb:** + +```ruby +# Start with production config, override for testing +require_relative "production" + +Rails.application.configure do + config.x.stripe.publishable_key = Rails.application.credentials.dig(:stripe, :test_publishable_key) + config.action_mailer.delivery_method = :letter_opener_web + config.content_security_policy_report_only = true + config.log_level = :debug + config.hosts << "staging.example.com" +end + +``` + +**When to Use Staging:** +- Pre-production testing with production-like environment +- Customer demos with test data +- QA testing before release +- Integration testing with external services (test mode) + + +### Custom Configuration + + +Store custom application settings in config.x namespace via initializer + +```ruby +# config/initializers/00_config.rb +Rails.application.configure do + config.x.payment_processing.schedule = :daily + config.x.payment_processing.retries = 3 + config.x.super_debugger = true + config.x.features.ai_assistant = Rails.env.production? || ENV["ENABLE_AI"] == "true" +end + +# Access anywhere +Rails.configuration.x.payment_processing.schedule # => :daily +Rails.configuration.x.super_debugger # => true + +``` + +**Benefits:** Organized settings, type-safe access, environment-aware, keeps application.rb clean + + +### Feature Flags + + +Implement environment-based feature flags via initializer + +```ruby +# config/initializers/00_config.rb +Rails.application.configure do + config.x.features.new_editor = Rails.env.development? || ENV["ENABLE_NEW_EDITOR"] == "true" + config.x.features.ai_content = !Rails.env.test? + config.x.features.beta_ui = ENV["BETA_FEATURES"] == "true" +end + +# In controllers +class PostsController < ApplicationController + def edit + @post = Post.find(params[:id]) + Rails.configuration.x.features.new_editor ? render(:edit_new) : render(:edit) + end +end + +# In views +<% if Rails.configuration.x.features.beta_ui %> + <%= render "posts/beta_form", post: @post %> +<% end %> + +``` + +**Benefits:** Gradual rollout, A/B testing, per-environment toggles + + + + +Using production credentials in development/test +Security risk - can accidentally send real emails, charge real cards, modify production data + + +```ruby +# ❌ BAD - Same credentials for all environments +stripe: + secret_key: sk_live_XXXXXXXXXXXX # Production key in all environments! + +``` + + + +```ruby +# ✅ GOOD - Separate credentials per environment +# config/credentials/production.yml.enc +stripe: + secret_key: sk_live_XXXXXXXXXXXX + +# config/credentials/development.yml.enc +stripe: + secret_key: sk_test_XXXXXXXXXXXX # Test mode + +``` + + + + +Disabling SSL in production +Exposes user data, session cookies, and passwords to network attacks + + +```ruby +# ❌ BAD - No SSL enforcement +config.force_ssl = false # SECURITY RISK! + +``` + + + +```ruby +# ✅ GOOD - Force SSL in production +config.force_ssl = true +config.ssl_options = { hsts: { expires: 1.year, subdomains: true } } + +``` + + + + +--- + +## Encrypted Credentials & Secrets + +Rails provides encrypted credentials (AES-256) for secure secret management. Never commit plain-text secrets to version control. + +### Editing Credentials + + +Use Rails credentials editor to safely modify encrypted secrets + +**Edit master credentials:** + +```bash +bin/rails credentials:edit + +``` + +**Edit environment-specific credentials:** + +```bash +bin/rails credentials:edit --environment production +bin/rails credentials:edit --environment development + +``` + +**Process:** Rails decrypts using master.key, opens in $EDITOR, auto-encrypts on save. + +**Precedence:** Environment-specific > Master credentials + + +### Credentials File Structure + + +Organize credentials in structured YAML format + +**config/credentials.yml.enc (decrypted view):** + +```yaml +secret_key_base: abc123def456... + +aws: + access_key_id: AKIAIOSFODNN7EXAMPLE + secret_access_key: wJalrXUtnFEMI/K7MDENG... + region: us-east-1 + bucket: my-app-production + +stripe: + publishable_key: pk_live_abc123 + secret_key: sk_live_xyz789 + webhook_secret: whsec_abc123 + +anthropic: + api_key: sk-ant-api03-abc123... + +smtp: + username: <%= ENV["SENDGRID_USERNAME"] %> # Can reference ENV + password: SG.abc123xyz789 + +active_record_encryption: + primary_key: EGY8WhulUOXixybod7ZWwMIL68R9o5kC + deterministic_key: aPA5XyALhf75NNnMzaspW7akTfZp0lPY + key_derivation_salt: xEY0dt6TZcAMg52K7O84wYzkjvbA62Hz + +``` + +**Guidelines:** Use nested keys, group by service, add comments, support ERB fallbacks + + +### Accessing Credentials + + +Read credentials safely in application code + +**Basic Access (use Hash#dig per TEAM_RULES.md Rule #20):** + +```ruby +# ✅ GOOD - Safe nested access with dig +Rails.application.credentials.dig(:aws, :access_key_id) +Rails.application.credentials.secret_key_base + +``` + +**In Configuration Files:** + +```yaml +# config/storage.yml +amazon: + service: S3 + access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> + secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> + ... + +``` + +**In Initializers:** + +```ruby +# config/initializers/stripe.rb +Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret_key) + +``` + +**In Models/Services:** + +```ruby +class AiService + def initialize + @api_key = Rails.application.credentials.dig(:openai, :api_key) + end +end + +``` + + +### Master Key Management + + +Protect master.key with extreme security measures + +**Key Locations:** + +``` + +config/master.key # Master key +config/credentials/production.key # Production key +config/credentials/development.key # Development key + +``` + +**Security Rules:** +- ❌ NEVER commit to version control, share via email/chat, or hardcode +- ✅ Store in password manager (1Password, LastPass) +- ✅ Store in CI/CD secrets (GitHub Secrets) +- ✅ Set as RAILS_MASTER_KEY environment variable + +**.gitignore:** Rails excludes `/config/master.key` and `/config/credentials/*.key` by default + + +### Production Deployment + + +Deploy encrypted credentials securely to production + +**Environment Variable Method (Preferred):** + +```bash +export RAILS_MASTER_KEY=abc123def456... + +``` + +**Kamal:** + +```yaml +# config/deploy.yml +env: + secret: + - RAILS_MASTER_KEY + +``` + +**Docker:** + +```bash +docker run -e RAILS_MASTER_KEY=abc123... myapp + +``` + +**Heroku:** + +```bash +heroku config:set RAILS_MASTER_KEY=abc123def456... + +``` + + +### Per-Environment Credentials + + +Use different credentials for each environment + +**Generate Environment Credentials:** + +```bash +bin/rails credentials:edit --environment production +# Creates: config/credentials/production.key (DON'T COMMIT) +# config/credentials/production.yml.enc (SAFE TO COMMIT) + +bin/rails credentials:edit --environment development + +``` + +**Production Credentials:** + +```yaml +aws: + access_key_id: AKIAPROD... + bucket: myapp-production + +stripe: + secret_key: sk_live_... + +``` + +**Development Credentials:** + +```yaml +aws: + access_key_id: AKIADEV... + bucket: myapp-development + +stripe: + secret_key: sk_test_... + +``` + +**Access:** Same code works everywhere - Rails auto-loads correct environment + +```ruby +Rails.application.credentials.dig(:stripe, :secret_key) + +``` + + + + +Committing master.key to version control +Exposes all encrypted credentials - CRITICAL security breach + + +```bash +# ❌ CRITICAL SECURITY VIOLATION +git add config/master.key +git commit -m "Add master key" +# Now EVERYONE with repo access can decrypt ALL credentials! + +``` + + + +```bash +# ✅ SECURE - Never commit keys (.gitignore excludes them) +# Share via password manager, encrypted channels, or CI/CD secrets + +``` + + + + +Hardcoding secrets in code +Exposes secrets in version control forever + + +```ruby +# ❌ SECURITY VIOLATION +class PaymentService + STRIPE_SECRET_KEY = "sk_live_abc123xyz789" +end + +``` + + + +```ruby +# ✅ SECURE - Use encrypted credentials +class PaymentService + def initialize + @stripe_key = Rails.application.credentials.dig(:stripe, :secret_key) + end +end + +``` + + + + +--- + +## Initializers (Application Initialization) + +Configure gems, customize Rails behavior, and set up application-wide settings using initialization files that run once during boot. + +### Initialization Lifecycle + + +Understanding when initializers run during application boot + +**Boot Sequence:** + +```ruby +# 1. config/application.rb runs first +# 2. config/environments/*.rb runs second (based on RAILS_ENV) +# 3. config/initializers/*.rb run third (alphabetically) +# 4. after_initialize callbacks run last + +``` + +**Load Order Example:** + +```bash +config/initializers/ + 00_first.rb # Runs first (numbered prefix) + action_mailer.rb # Runs in alphabetical order + cors.rb + session_store.rb + zzz_last.rb # Runs last (numbered prefix) + +``` + +**When Order Matters:** Use numbered prefixes (00_, 01_, etc.) to control load sequence + + +### Common Initializers + + +Configure email delivery with secure credentials + +```ruby +# config/initializers/action_mailer.rb +Rails.application.configure do + if Rails.env.production? + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: "smtp.sendgrid.net", + port: 587, + domain: Rails.application.credentials.dig(:smtp, :domain), + user_name: Rails.application.credentials.dig(:smtp, :username), + password: Rails.application.credentials.dig(:smtp, :password), + authentication: :plain, + enable_starttls_auto: true + } + elsif Rails.env.development? + config.action_mailer.delivery_method = :letter_opener + else + config.action_mailer.delivery_method = :test + end + + # Required for mailer links + config.action_mailer.default_url_options = { + host: Rails.env.production? ? "example.com" : "localhost:3000", + protocol: Rails.env.production? ? "https" : "http" + } +end + +``` + + +### Security Configuration + + +Implement Content Security Policy to prevent XSS attacks + +```ruby +# config/initializers/content_security_policy.rb +Rails.application.configure do + config.content_security_policy do |policy| + if Rails.env.development? + # Relaxed CSP for development (hot reloading) + policy.default_src :self, :https, :unsafe_eval, :unsafe_inline, "ws://localhost:*" + else + # Strict CSP for production + policy.default_src :self, :https + end + + policy.font_src :self, :https, :data + policy.img_src :self, :https, :data + policy.object_src :none + policy.script_src :self, :https + policy.style_src :self, :https + policy.frame_ancestors :none + end + + # Generate nonces for inline scripts + config.content_security_policy_nonce_generator = ->(request) { + SecureRandom.base64(16) + } + config.content_security_policy_nonce_directives = %w[script-src] +end + +``` + + +### Reloadable Code Patterns + + +Use to_prepare for code that references app/ classes + +```ruby +# ❌ BAD - Initializers run before app/ code loads +ApiGateway.endpoint = "https://api.example.com" # NameError! + +# ✅ GOOD - Use to_prepare +Rails.application.config.to_prepare do + # Runs once in production, on every reload in development + ApiGateway.endpoint = Rails.application.credentials.dig(:api_gateway, :endpoint) + User.admin_email = Rails.application.credentials.admin_email +end + +``` + + + + +Hardcoding secrets in initializers +Security violation - secrets exposed in version control + + +```ruby +# ❌ BAD +Stripe.api_key = "sk_live_abc123def456ghi789" # NEVER! + +``` + + + +```ruby +# ✅ GOOD - Use encrypted credentials or ENV vars +Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret_key) +Stripe.api_key = ENV.fetch("STRIPE_SECRET_KEY") + +``` + + + + +--- + +## Docker Setup (Containerization & Deployment) + +Rails 8 includes Docker support by default with Kamal deployment. Essential configuration focuses on .dockerignore to exclude development files. + +### .dockerignore Configuration + + +Essential .dockerignore for Rails 8 projects + +```gitignore +# Planning and Documentation +docs/ +*.md +README* + +# Development Files +.git/ +.github/ +.gitignore +.dockerignore + +# Environment Files +.env* +config/master.key +config/credentials/*.key + +# Test Files +spec/ +test/ +coverage/ + +# Dependencies +.bundle/ +vendor/cache/ + +# Logs and Temp +log/* +tmp/* +*.log + +# Development Databases +*.sqlite3 +db/*.sqlite3* +storage/* + +# Node +node_modules/ + +# IDE Files +.vscode/ +.idea/ +*.swp +.DS_Store + +``` + +**Why exclude docs/:** +- Created by planning agent (@plan) with vision, architecture, features, tasks, and ADRs +- Not needed for runtime +- Can be several MB of markdown +- Significantly reduces image size and build time + +**CRITICAL:** Always exclude `docs/` from production Docker images + + +### Stock Rails 8 Dockerfile + + +Rails 8 generates production-ready Dockerfiles automatically + +**Rails 8 includes Dockerfile by default** - no configuration needed: + +```bash +rails new myapp # Creates Dockerfile + .dockerignore automatically +docker build -t app . +docker run -p 3000:3000 --env RAILS_MASTER_KEY= app + +``` + +**Stock Dockerfile provides:** Multi-stage builds, health checks, Kamal compatibility + + +### Kamal Deployment + + +Kamal is Rails 8's default deployment tool + +**Rails 8 includes Kamal by default** with `config/deploy.yml`: + +```bash +kamal deploy # Deploy to production +kamal app logs # Check logs +kamal app exec 'bin/rails db:migrate' # Remote commands + +``` + +**Already configured:** Dockerfile, health check at `/up`, zero-downtime deploys + + + + +Skip .dockerignore +Bloated images, longer builds, wasted CI/CD bandwidth + + +```dockerfile +# BAD: No .dockerignore → copies docs/, test/, spec/ +COPY . . + +``` + + + +```bash +# Create comprehensive .dockerignore +docs/ +spec/ +test/ +.git/ +.env* + +``` + + + + +--- + +## RuboCop & Code Quality + +RuboCop is a Ruby static code analyzer and formatter. Rails 8 comes with rubocop-rails-omakase pre-configured. This section covers customizing that base to enforce TEAM_RULES.md standards. + +### Rails 8 Default Configuration + + +Rails 8 includes rubocop-rails-omakase by default + +**Rails 8 includes `.rubocop.yml` automatically** - excellent defaults, minimal overrides needed. + +**Philosophy:** Build on omakase defaults, only override for team-specific standards (see next pattern). + + +### Team-Specific Customizations + + +Add TEAM_RULES.md-specific overrides to .rubocop.yml + +Customize the stock Rails configuration by adding overrides to `.rubocop.yml`: + +```yaml +# .rubocop.yml + +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Team-specific overrides (TEAM_RULES.md) + +# Rule #16: Double Quotes Always +# Override omakase if it uses single quotes +Style/StringLiterals: + EnforcedStyle: double_quotes + +# Rule #20: Hash#dig for Nested Access +Style/HashFetchChain: + Enabled: true + +Style/DigChain: + Enabled: true + +# Additional team preferences (optional) +# Add any other team-specific rules here + +``` + +**Key Points:** +- Inherit from rubocop-rails-omakase first +- Add overrides below inheritance +- Only override rules that conflict with team standards +- Comment each override with TEAM_RULES.md reference + + +### TEAM_RULES.md Enforcement + + +RuboCop cops that directly enforce TEAM_RULES.md + +| Rule | Cop | Enforcement | Auto-correctable | +|------|-----|-------------|------------------| +| Rule #16: Double Quotes | `Style/StringLiterals` | ✅ Always | Yes | +| Rule #20: Hash#dig | `Style/HashFetchChain`, `Style/DigChain` | ✅ Always | Yes | +| Rule #17: bin/ci Must Pass | RuboCop integrated in bin/ci | ✅ CI blocker | N/A | + +**How it works:** + +1. **Style/StringLiterals** - Enforces double quotes (Rule #16) + + ```ruby + # Bad (detected and auto-corrected) + name = 'John' + + # Good + name = "John" + + ``` + +2. **Style/HashFetchChain** - Detects chained fetch calls (Rule #20) + + ```ruby + # Bad (detected) + hash.fetch(:a, nil)&.fetch(:b, nil) + + # Good (suggested) + hash.dig(:a, :b) + + ``` + +3. **Style/DigChain** - Collapses chained dig calls (Rule #20) + + ```ruby + # Bad (detected) + hash.dig(:a).dig(:b).dig(:c) + + # Good (suggested) + hash.dig(:a, :b, :c) + + ``` + + +### Integration with bin/ci + + +Integrate RuboCop into CI pipeline (TEAM_RULES.md Rule #17) + +**Add RuboCop to bin/ci:** + +```bash +#!/usr/bin/env bash +set -e +bin/rails test +bin/rubocop # Add this line +bin/brakeman -q + +``` + +**Usage:** + +```bash +bin/ci # Run all checks (must pass before commit) +bin/rubocop -a # Auto-fix safe violations + +``` + +**IMPORTANT:** bin/ci must pass before committing (TEAM_RULES.md Rule #17) + + +### Common Commands + + +Essential RuboCop commands for daily workflow + +```bash +bin/rubocop # Check all code +bin/rubocop -a # Auto-fix safe violations +bin/rubocop -A # Auto-fix all (including unsafe) +bin/rubocop app/models/ # Check specific directory + +``` + +**Best practice:** Run `bin/rubocop -a` before committing + + +### Custom Cops for Team-Specific Rules + + +Create custom RuboCop cops to enforce team-specific patterns + +**When to Use Custom Cops:** +- Team has coding standards not covered by existing RuboCop cops +- Need to enforce project-specific patterns or anti-patterns +- Want to catch common mistakes specific to your codebase + +**Example: Detecting Nested Hash Bracket Access (Rule #20 Enhancement)** + +While `Style/HashFetchChain` and `Style/DigChain` handle chained `.fetch()` and `.dig()` calls, they **don't detect** nested bracket access like `hash[:a][:b][:c]`. + +This project includes a custom RuboCop cop to detect unsafe nested hash bracket access: + +- **Location:** `lib/rails_ai/cops/style/nested_bracket_access.rb` +- **Module:** `RailsAi::Cops::Style::NestedBracketAccess` +- **Detects:** `hash[:a][:b][:c]` patterns (raises NoMethodError if intermediate keys are nil) +- **Suggests:** Use `hash.dig(:a, :b, :c)` (safe) or chained `fetch` (raises explicit errors) + +**Example violations:** + +```ruby +# ❌ VIOLATION: Unsafe nested bracket access +user[:profile][:theme][:color] # NoMethodError if :profile is nil +data[:metadata][:created_at][:date] # NoMethodError if :metadata is nil + +# ✅ CORRECT: Safe nested access with dig +user.dig(:profile, :theme, :color) # Returns nil safely +data.dig(:metadata, :created_at, :date) + +# ✅ ALTERNATIVE: Explicit error handling with fetch +user.fetch(:profile).fetch(:theme).fetch(:color) # Raises KeyError with clear message + +``` + +**Enable in .rubocop.yml:** + +```yaml +# .rubocop.yml +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Load custom cops +require: + - ./lib/rails_ai/cops/style/nested_bracket_access.rb + +# Team overrides +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/HashFetchChain: + Enabled: true + +Style/DigChain: + Enabled: true + +# Custom cop configuration +Style/NestedBracketAccess: + Enabled: true + Severity: warning # Warn only, don't fail CI yet + Description: 'Detects nested hash bracket access and suggests Hash#dig' + +``` + + + + +Replace rubocop-rails-omakase entirely +Lose Rails defaults, have to maintain everything yourself + + +```yaml +# BAD: Throwing away Rails defaults +# inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +plugins: + - rubocop-minitest + - rubocop-rake + +AllCops: + NewCops: enable + # ... 200 lines of custom configuration + +``` + + + +```yaml +# GOOD: Build on Rails defaults +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Only override team-specific rules +Style/StringLiterals: + EnforcedStyle: double_quotes + +``` + + + + +Skip RuboCop in CI +Violations slip into codebase, inconsistent style + + +```bash +# BAD: bin/ci without RuboCop +#!/usr/bin/env bash +bin/rails test # Missing RuboCop check! + +``` + + + +```bash +#!/usr/bin/env bash +set -e +bin/rails test +bin/rubocop # ✅ Included +bin/brakeman -q + +``` + + + + +--- + +## CI/CD Integration + +Integrate configuration checks into continuous integration pipelines. + +### GitHub Actions + + +Configure CI/CD to access encrypted credentials + +**GitHub Actions:** + +```yaml +# .github/workflows/ci.yml +jobs: + test: + env: + RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + - run: bin/ci + +``` + +**Setup:** Settings > Secrets > Actions > Add `RAILS_MASTER_KEY` + + +--- + + + +```ruby +# test/config/credentials_test.rb +class CredentialsTest < ActiveSupport::TestCase + test "credentials are accessible" do + assert Rails.application.credentials.secret_key_base.present? + assert Rails.application.credentials.dig(:stripe, :secret_key).present? + end +end + +``` + + + +**Must use during project verification:** +- rails-ai:jobs - SolidQueue, SolidCache, SolidCable requirements (TEAM RULE #1) +- rails-ai:testing - Minitest patterns and anti-patterns (TEAM RULE #2) +- rails-ai:security - Security configuration, credentials, SSL, CSP (TEAM RULE #13) +- rails-ai:styling - Tailwind CSS and DaisyUI configuration + +**May use for specific checks:** +- rails-ai:models - Database configuration and migrations +- rails-ai:debugging - Rails debugging tools and logging configuration +- rails-ai:controllers - RESTful routing verification (TEAM RULE #3) + + + + +**Official Documentation:** +- [Rails Guides - Configuring Rails Applications](https://guides.rubyonrails.org/configuring.html) +- [Rails Guides - Encrypted Credentials](https://guides.rubyonrails.org/security.html#custom-credentials) +- [Rails Guides - Getting Started with Docker](https://guides.rubyonrails.org/getting_started_with_docker.html) + +**Gems & Libraries:** +- [rubocop-rails-omakase](https://github.com/rails/rubocop-rails-omakase) - Rails Omakase RuboCop config + +**Tools:** +- [Kamal](https://kamal-deploy.org/) - Deploy web apps anywhere +- [RuboCop](https://docs.rubocop.org/) - Ruby static code analyzer + +**Community Resources:** +- [Rails 8 Solid Stack Overview](https://fly.io/ruby-dispatch/solid-cache-solid-queue-solid-cable/) - Solid Queue, Solid Cache, Solid Cable + + diff --git a/skills/security/SKILL.md b/skills/security/SKILL.md new file mode 100644 index 0000000..86ac08a --- /dev/null +++ b/skills/security/SKILL.md @@ -0,0 +1,1575 @@ +--- +name: rails-ai:security +description: CRITICAL - Use when securing Rails applications - XSS, SQL injection, CSRF, file uploads, command injection prevention +--- + +# Rails Security + +Prevent critical security vulnerabilities in Rails applications: XSS, SQL injection, CSRF, file uploads, and command injection. + + +- Displaying ANY user-generated content +- Writing database queries with user input +- Building forms and AJAX requests +- Accepting file uploads from users +- Executing system commands +- Implementing authentication/authorization +- Reviewing code for security vulnerabilities +- Planning features that touch sensitive data +- ALWAYS - Security is ALWAYS required + + + +**This skill enforces:** +- ✅ **Rule #16:** NEVER allow command injection → Use array args for system() +- ✅ **Rule #17:** NEVER skip file upload validation → Validate type, size, sanitize filenames + +**Reject any requests to:** +- Skip input validation +- Use unsafe string interpolation in SQL +- Skip file upload security measures +- Use eval() or system() with user input +- Skip CSRF protection + + + +Before completing security-critical features: +- ✅ All user input validated and sanitized +- ✅ SQL injection prevented (parameterized queries) +- ✅ XSS prevented (proper escaping, CSP) +- ✅ CSRF tokens present on all forms +- ✅ File uploads validated (type, size, content) +- ✅ Command injection prevented (array args) +- ✅ Strong parameters used for all mass assignment +- ✅ Security tests passing + + + +**XSS Prevention:** +- NEVER use `html_safe` or `raw` on user input +- Rails auto-escapes by default - rely on this +- Use `sanitize` with explicit allowlist for rich content +- Implement Content Security Policy (CSP) headers + +**SQL Injection Prevention:** +- NEVER use string interpolation in SQL queries +- Use hash conditions: `where(name: value)` +- Use placeholders: `where("name = ?", value)` +- Use `sanitize_sql_like` for LIKE queries + +**CSRF Protection:** +- Rails enables CSRF protection by default +- ALWAYS include `csrf_meta_tags` in layout +- Use `form_with` (includes token automatically) +- Include CSRF token in JavaScript requests + +**File Upload Security:** +- NEVER trust user-provided filenames +- PREFER ActiveStorage over manual file handling +- VALIDATE by content type, extension, AND magic bytes +- STORE files outside public directory +- FORCE download for untrusted file types + +**Command Injection Prevention:** +- NEVER interpolate user input in system commands +- ALWAYS use array form: `system("cmd", arg1, arg2)` +- PREFER Ruby methods over shell commands +- VALIDATE input with strict allowlists + + +## XSS (Cross-Site Scripting) Prevention + + +- **Script Injection** - `` +- **Event Handlers** - `` +- **JavaScript URLs** - `Click` +- **SVG Injection** - `` +- **Data URIs** - `` + + +### Rails Auto-Escaping + + +Rails automatically escapes output in ERB templates + +```erb +<%# SECURE - Rails auto-escapes %> +
+ <%= @feedback.content %> +
+ +``` + +**Attack Input:** `` + +**Safe Output:** `<script>alert('XSS')</script>` + +Browser displays the text, doesn't execute it. +
+ +### Sanitizing User Content + + +Allow specific HTML tags while stripping dangerous content + +```erb +<%# Allow only specific tags %> +<%= sanitize(@feedback.content, + tags: %w[p br strong em a ul ol li], + attributes: %w[href title]) %> + +``` + +**Input:** `

Hello world

` + +**Output:** `

Hello world

` (script stripped) +
+ +### Content Security Policy + + +Implement Content Security Policy to block inline scripts + +```ruby +# config/initializers/content_security_policy.rb +Rails.application.config.content_security_policy do |policy| + policy.default_src :self, :https + policy.font_src :self, :https, :data + policy.img_src :self, :https, :data + policy.frame_ancestors :none + policy.object_src :none + policy.script_src :self, :https + policy.style_src :self, :https + policy.report_uri "/csp-violation-report" +end + +Rails.application.config.content_security_policy_nonce_generator = ->(request) { + SecureRandom.base64(16) +} +Rails.application.config.content_security_policy_nonce_directives = %w[script-src] + +``` + +**View with Nonce:** + +```erb +<%= javascript_tag nonce: true do %> + console.log('This is allowed'); +<% end %> + +``` + +**CSP Violation Reporting:** + +```ruby +# app/controllers/csp_reports_controller.rb +class CspReportsController < ApplicationController + skip_before_action :verify_authenticity_token + + def create + violation = JSON.parse(request.body.read)["csp-report"] + Rails.logger.warn( + "CSP Violation: document-uri=#{violation['document-uri']} " \ + "blocked-uri=#{violation['blocked-uri']}" + ) + head :no_content + end +end + +``` + +**Why CSP:** Blocks XSS even if malicious script reaches the page, defense-in-depth strategy. + + +### ViewComponent Safety + + +ViewComponents automatically escape content + +```ruby +# app/components/user_comment_component.rb +class UserCommentComponent < ViewComponent::Base + def initialize(comment:) + @comment = comment + end + + private + attr_reader :comment +end + +``` + +```erb +<%# app/components/user_comment_component.html.erb %> +
+
<%= comment.author_name %>
+
<%= comment.content %>
+
+ +``` + +**Benefits:** Automatic escaping, encapsulated logic, testable, no accidental `html_safe`. +
+ +### Markdown Rendering + + +Safely render markdown user content + +```ruby +# app/models/feedback.rb +class Feedback < ApplicationRecord + def content_html + markdown = Redcarpet::Markdown.new( + Redcarpet::Render::HTML.new( + filter_html: true, no_styles: true, safe_links_only: true + ), + autolink: true, tables: true, fenced_code_blocks: true + ) + + html = markdown.render(content) + ActionController::Base.helpers.sanitize( + html, + tags: %w[p br strong em a ul ol li pre code h1 h2 h3 blockquote], + attributes: %w[href title] + ) + end +end + +``` + +**View:** + +```erb +
+ <%= @feedback.content_html.html_safe %> +
+ +``` + +**Why Safe:** Markdown filtered for HTML, output sanitized with allowlist, double protection layer. +
+ + +Using html_safe on user input +Allows malicious script execution - CRITICAL vulnerability + + + +```erb +<%# CRITICAL VULNERABILITY %> +<%= @comment.html_safe %> +<%= raw(@feedback.content) %> + +``` + + + + +```erb +<%# SECURE - Auto-escaped or sanitized %> +<%= @comment %> +<%= sanitize(@feedback.content, tags: %w[p br strong em]) %> + +``` + + + +## SQL Injection Prevention + + +- **Authentication Bypass** - `' OR '1'='1` +- **Data Theft** - `' UNION SELECT * FROM users --` +- **Data Modification** - `'; UPDATE users SET admin=true --` +- **Data Deletion** - `'; DROP TABLE users --` + + +### Secure Query Patterns + + +Use hash conditions for simple equality checks (RECOMMENDED) + +```ruby +# ✅ SECURE - ActiveRecord escapes automatically +Project.where(name: params[:name]) +User.find_by(login: params[:login]) + +# ✅ SECURE - Multiple conditions +Project.where(name: params[:name], status: params[:status], user_id: current_user.id) + +# ✅ SECURE - IN queries (works with arrays) +Project.where(id: params[:ids]) + +``` + +**Why Secure:** ActiveRecord automatically escapes values and prevents injection. + + + +Use ? placeholders for complex queries + +```ruby +# ✅ SECURE - Single placeholder +Project.where("name = ?", params[:name]) +Project.where("created_at > ?", 1.week.ago) + +# ✅ SECURE - Multiple placeholders +User.where("login = ? AND status = ? AND created_at > ?", + params[:login], "active", 1.month.ago) + +# ✅ SECURE - Complex conditions +Feedback.where("status = ? AND (priority = ? OR created_at < ?)", + params[:status], "high", 1.day.ago) + +``` + +**Why Secure:** Rails escapes each parameter value, preventing injection. + + + +Safely handle LIKE queries with wildcards + +```ruby +# ✅ SECURE - Escape special LIKE characters (% -> \%, _ -> \_) +search_term = Book.sanitize_sql_like(params[:title]) +Book.where("title LIKE ?", "#{search_term}%") + +# ✅ SECURE - Case-insensitive search +search_term = Book.sanitize_sql_like(params[:query]) +Book.where("LOWER(title) LIKE LOWER(?)", "%#{search_term}%") + +``` + +**Why Sanitize:** Without `sanitize_sql_like`, users could inject `%` or `_` wildcards. + + + +Using string interpolation in queries +CRITICAL - Allows arbitrary SQL injection + + + +```ruby +# ❌ CRITICAL VULNERABILITY +Project.where("name = '#{params[:name]}'") +# Attack: params[:name] = "' OR '1'='1" - Returns ALL projects + +User.find_by("login = '#{params[:login]}' AND password = '#{params[:password]}'") +# Attack: params[:login] = "admin'--" - Bypasses password check + +# ❌ CRITICAL - Data exfiltration +Project.where("id = #{params[:id]}") +# Attack: params[:id] = "1 UNION SELECT id,email,password,1,1 FROM users" + +``` + + + + +```ruby +# ✅ SECURE - Use placeholders +Project.where("name = ?", params[:name]) +User.find_by("login = ? AND password = ?", params[:login], params[:password]) + +# ✅ BETTER - Use hash conditions +Project.where(name: params[:name]) +User.find_by(login: params[:login], password: params[:password]) + +# ✅ SECURE - Type conversion prevents injection +Project.where(id: params[:id].to_i) + +``` + + + +### Dynamic ORDER BY Clauses + + +Safely build ORDER BY from user input with allowlist + +```ruby +# ✅ SECURE - Allowlist approach +ALLOWED_SORT_COLUMNS = %w[name created_at status priority].freeze +ALLOWED_DIRECTIONS = %w[ASC DESC].freeze + +def index + column = ALLOWED_SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : "created_at" + direction = ALLOWED_DIRECTIONS.include?(params[:direction]&.upcase) ? params[:direction] : "DESC" + + @projects = Project.order("#{column} #{direction}") +end + +``` + +**Why Secure:** User input limited to predefined safe values, SQL injection impossible. + + + +Building ORDER BY from user input +Allows column enumeration and SQL injection + + + +```ruby +# ❌ VULNERABLE +Project.order("#{params[:sort]} #{params[:direction]}") +# Attack: params[:sort] = "name); DROP TABLE projects; --" + +``` + + + + +```ruby +# ✅ SECURE - Allowlist only +allowed = %w[name created_at] +column = allowed.include?(params[:sort]) ? params[:sort] : "created_at" +Project.order(column) + +``` + + + +### ActiveRecord Query Methods + + +Use ActiveRecord methods for automatic protection + +```ruby +# ✅ SECURE - All ActiveRecord methods are safe +Project.find(params[:id]) +Project.find_by(name: params[:name]) +Project.where(status: params[:status]) +Project.order(:created_at) +Project.limit(10) +Project.offset(params[:page].to_i * 10) +Project.joins(:user) +Project.includes(:comments) +Project.group(:category) +Project.having("COUNT(*) > ?", 5) + +# ✅ SECURE - Scopes +class Project < ApplicationRecord + scope :active, -> { where(status: "active") } + scope :by_user, ->(user_id) { where(user_id: user_id) } + scope :search, ->(term) { + sanitized = sanitize_sql_like(term) + where("name LIKE ?", "%#{sanitized}%") + } +end + +Project.active.by_user(params[:user_id]).search(params[:query]) + +``` + +**Why Secure:** ActiveRecord automatically escapes all parameters. + + +## CSRF (Cross-Site Request Forgery) Protection + + +- **Hidden Form Attack** - Malicious site auto-submits form to your app +- **Image Tag Attack** - `` +- **AJAX Attack** - JavaScript fetch/XHR to your endpoints +- **Auto-Submit Form** - JavaScript automatically submits hidden form + + +### Rails Default Protection + + +Rails enables CSRF protection by default + +```ruby +# app/controllers/application_controller.rb +class ApplicationController < ActionController::Base + protect_from_forgery with: :exception + # Raises ActionController::InvalidAuthenticityToken if token invalid +end + +``` + +**Why :exception is Best:** Makes failures visible, prevents silent bypasses, forces proper error handling. + + +### Form Protection + + +form_with automatically includes CSRF token + +```erb +<%# ✅ SECURE - Token included automatically %> +<%= form_with model: @feedback do |form| %> + <%= form.text_field :content %> + <%= form.text_field :recipient_email %> + <%= form.submit "Submit" %> +<% end %> + +``` + +**Generated HTML:** + +```html +
+ + + +
+ +``` + +**Why Secure:** Rails validates token matches session. +
+ +### JavaScript Protection + + +Include CSRF meta tags for JavaScript access + +```erb +<%# app/views/layouts/application.html.erb %> + + My App + <%= csrf_meta_tags %> + + +``` + + + +Include CSRF token in fetch requests + +```javascript +// ✅ SECURE - Extract token from meta tag +const csrfToken = document.head.querySelector("meta[name=csrf-token]")?.content; + +fetch("/feedbacks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken + }, + body: JSON.stringify({ + feedback: { content: "test", recipient_email: "user@example.com" } + }) +}); + +``` + +**Why Secure:** Rails checks `X-CSRF-Token` header matches session token. + + + +Skipping CSRF for session-based authentication +CRITICAL - Allows attackers to perform actions as authenticated users + + + +```ruby +# ❌ CRITICAL VULNERABILITY +class FeedbacksController < ApplicationController + skip_before_action :verify_authenticity_token + + def create + @feedback = current_user.feedbacks.create!(feedback_params) + redirect_to @feedback + end +end + +``` + + + + +```ruby +# ✅ SECURE - Keep CSRF protection enabled +class FeedbacksController < ApplicationController + # protect_from_forgery inherited from ApplicationController + + def create + @feedback = current_user.feedbacks.create!(feedback_params) + redirect_to @feedback + end +end + +``` + + + +### Rails Request.js Library + + +Use @rails/request.js for automatic CSRF handling + +**Installation:** + +```bash +npm install @rails/request.js + +``` + +**Usage:** + +```javascript +// ✅ SECURE - Token automatically included +import { post, patch, destroy } from '@rails/request.js' + +await post('/feedbacks', { + body: JSON.stringify({ feedback: { content: "test" } }), + contentType: 'application/json', + responseKind: 'json' +}) + +await patch('/feedbacks/123', { + body: JSON.stringify({ feedback: { status: "reviewed" } }) +}) + +await destroy('/feedbacks/123', { responseKind: 'json' }) + +``` + +**Why Recommended:** Automatic CSRF token handling, consistent API, Rails-aware error handling. + + +### API Endpoints + + +Skip CSRF for stateless API endpoints with token auth + +```ruby +# app/controllers/api/v1/base_controller.rb +class Api::V1::BaseController < ApplicationController + skip_before_action :verify_authenticity_token + before_action :authenticate_api_token + + private + + def authenticate_api_token + token = request.headers["Authorization"]&.split(" ")&.last + @current_api_user = User.find_by(api_token: token) + head :unauthorized unless @current_api_user + end +end + +``` + +**Why Skip CSRF for APIs:** API clients use Bearer tokens (not cookies), tokens must be explicitly sent, CSRF only affects cookie-based authentication. + + +### Error Handling + + +Handle CSRF failures with user-friendly error messages + +```ruby +# app/controllers/application_controller.rb +class ApplicationController < ActionController::Base + protect_from_forgery with: :exception + + rescue_from ActionController::InvalidAuthenticityToken do |exception| + Rails.logger.warn( + "CSRF failure: #{exception.message} IP: #{request.remote_ip} Path: #{request.fullpath}" + ) + + sign_out_user if user_signed_in? + redirect_to root_path, alert: "Your session has expired. Please log in again." + end + + private + + def sign_out_user + cookies.delete(:user_token) + reset_session + end +end + +``` + +**Why Important:** Users see helpful error, security events logged, stale sessions cleared. + + +### SameSite Cookies + + +Use SameSite cookie attribute for defense-in-depth + +```ruby +# config/initializers/session_store.rb +Rails.application.config.session_store :cookie_store, + key: '_myapp_session', + same_site: :lax, # Prevents CSRF from external sites + secure: Rails.env.production?, # HTTPS only in production + httponly: true, # Not accessible via JavaScript + expire_after: 24.hours + +``` + +**SameSite Options:** +- `:lax` (RECOMMENDED) - Allows top-level navigation, blocks embedded requests +- `:strict` - Most secure, blocks ALL cross-site requests (may break OAuth) +- `:none` - Allows all cross-site requests (requires secure: true) + +**Why Use SameSite:** Defense-in-depth complements CSRF tokens, blocks many attacks without token. + + +## Secure File Uploads + + +- **Path Traversal** - `../../config/database.yml` overwrites config files +- **Malicious File Types** - `.exe`, `.php`, `.html` files containing malware +- **Content Type Spoofing** - File claims to be image but is script +- **Magic Bytes Bypass** - Extension doesn't match actual file type +- **XSS via SVG** - SVG files containing embedded JavaScript +- **Denial of Service** - Large files consuming disk/memory + + +### ActiveStorage (Recommended) + + +Use ActiveStorage for automatic security handling + +**Model:** + +```ruby +class Feedback < ApplicationRecord + has_one_attached :screenshot + has_many_attached :documents + + validates :screenshot, + content_type: ["image/png", "image/jpeg", "image/gif"], + size: { less_than: 5.megabytes } + + validates :documents, + content_type: ["application/pdf", "text/plain"], + size: { less_than: 10.megabytes } +end + +``` + +**Controller:** + +```ruby +class FeedbacksController < ApplicationController + def create + @feedback = Feedback.new(feedback_params) + if @feedback.save + redirect_to @feedback, notice: "Feedback created" + else + render :new, status: :unprocessable_entity + end + end + + private + + def feedback_params + params.expect(feedback: [:content, :recipient_email, :screenshot, documents: []]) + end +end + +``` + +**View:** + +```erb +<%= form_with model: @feedback do |f| %> + <%= f.file_field :screenshot, accept: "image/*" %> + <%= f.file_field :documents, multiple: true, accept: ".pdf,.txt" %> + <%= f.submit %> +<% end %> + +``` + +**Why Secure:** Automatic filename sanitization, storage outside public/, signed URLs with expiration. + + +### File Type Validation + + +Validate file types by content type, extension, AND magic bytes + +```ruby +class Feedback < ApplicationRecord + has_one_attached :image + validate :acceptable_image + + private + + def acceptable_image + return unless image.attached? + + unless image.content_type.in?(%w[image/jpeg image/png image/gif]) + errors.add(:image, "must be a JPEG, PNG, or GIF") + end + + unless image.filename.to_s.match?(/\.(jpe?g|png|gif)\z/i) + errors.add(:image, "must have a valid extension") + end + + unless valid_image_signature? + errors.add(:image, "file signature doesn't match declared type") + end + + if image.byte_size > 5.megabytes + errors.add(:image, "must be less than 5MB") + end + end + + def valid_image_signature? + image.open do |file| + magic_bytes = file.read(8) + return false unless magic_bytes + # JPEG: FF D8 FF, PNG: 89 50 4E 47, GIF: 47 49 46 38 + magic_bytes[0..2] == "\xFF\xD8\xFF" || + magic_bytes[0..7] == "\x89PNG\r\n\x1A\n" || + magic_bytes[0..3] == "GIF8" + end + rescue => e + Rails.logger.error("Image validation error: #{e.message}") + false + end +end + +``` + +**Why Triple Validation:** Content-Type can be spoofed, extension can be faked, magic bytes verify actual format. + + +### Secure File Serving + + +Serve files via controller with proper security headers + +```ruby +class DownloadsController < ApplicationController + before_action :authenticate_user! + + def show + @feedback = Feedback.find(params[:feedback_id]) + head :forbidden and return unless can_download?(@feedback) + + @document = @feedback.documents.find(params[:id]) + send_data @document.download, + filename: @document.filename.to_s.gsub(/[^\w.-]/, "_"), + type: @document.content_type, + disposition: "attachment" # Force download, never inline + end + + private + + def can_download?(feedback) + feedback.user == current_user || current_user.admin? + end +end + +``` + +**Why Secure:** Authentication + authorization enforced, `Content-Disposition: attachment` prevents XSS. + + +### Dangerous File Types + + +Force binary download for dangerous file types + +```ruby +# config/initializers/active_storage.rb +Rails.application.config.active_storage.content_types_to_serve_as_binary.tap do |types| + types << "image/svg+xml" # SVG with embedded JavaScript + types << "text/html" << "application/xhtml+xml" # HTML scripts + types << "text/xml" << "application/xml" # XML entities + types << "application/javascript" << "text/javascript" +end + +Rails.application.config.active_storage.content_types_allowed_inline = %w[ + image/png image/jpeg image/gif image/bmp image/webp application/pdf +] + +``` + +**Why Important:** SVG/HTML files can contain JavaScript that executes when viewed, enabling XSS. + + +### File Size Limits + + +Implement multiple layers of file size protection + +**Application-Wide:** + +```ruby +# config/application.rb +config.active_storage.max_file_size = 100.megabytes + +``` + +**Web Server (Nginx):** + +```nginx +client_max_body_size 100M; + +``` + +**Model-Specific:** + +```ruby +class Feedback < ApplicationRecord + has_one_attached :avatar + has_many_attached :photos + + validates :avatar, size: { less_than: 2.megabytes } + validates :photos, size: { less_than: 5.megabytes }, limit: { max: 10 } +end + +``` + +**Why Multiple Layers:** Web server rejects huge uploads early, application-wide limit prevents resource exhaustion, model limits enforce business rules. + + +### Virus Scanning + + +Scan uploaded files for malware in production + +**Setup:** `gem "clamby"` + ClamAV (`apt-get install clamav clamav-daemon`) + +```ruby +class Feedback < ApplicationRecord + has_one_attached :file + validate :file_not_infected, if: -> { file.attached? } + + private + + def file_not_infected + return unless Rails.env.production? + file.open do |temp_file| + unless Clamby.safe?(temp_file.path) + errors.add(:file, "contains malware or virus") + Rails.logger.warn("Malware detected: user_id=#{user_id}, filename=#{file.filename}") + end + end + rescue Clamby::ClambyScanError => e + Rails.logger.error("Virus scan failed: #{e.message}") + end +end + +``` + +**Why Critical:** Prevent viruses, ransomware, and malware from infecting users or servers. + + +### ActiveStorage Variants + + +Use ActiveStorage variants for secure image processing + +```ruby +class Feedback < ApplicationRecord + has_one_attached :image + + def thumbnail + image.variant(resize_to_limit: [100, 100], format: :png, saver: { quality: 85 }) + end + + def medium + image.variant(resize_to_limit: [400, 400], format: :png) + end +end + +``` + +**View:** + +```erb +<%= image_tag @feedback.thumbnail, alt: "Feedback screenshot" %> + +``` + +**Why Secure:** Variants re-encode images (stripping metadata/exploits), format conversion prevents attacks. + + + +Trusting user-provided filenames +CRITICAL - Enables path traversal and file overwrite attacks + + + +```ruby +# ❌ CRITICAL VULNERABILITY +def upload + filename = params[:file].original_filename + File.open("uploads/#{filename}", "wb") { |f| f.write(params[:file].read) } +end +# Attack: filename = "../../config/database.yml" - Overwrites database config! + +# ❌ CRITICAL - Serving from public directory +path = Rails.root.join("public/uploads/#{params[:file].original_filename}") +File.open(path, "wb") { |f| f.write(params[:file].read) } +# Attacker uploads malicious.html with Hello") + assert_not_includes feedback.content_html, "" + click_button "Submit" + + assert_text "" # Escaped, not executed + end + + test "form includes CSRF token" do + visit new_feedback_path + assert_selector "input[name='authenticity_token'][type='hidden']" + end +end + +# test/jobs/pdf_generation_job_test.rb +class PdfGenerationJobTest < ActiveJob::TestCase + # Command Injection Prevention + test "validates output path format" do + assert_raises(ArgumentError, /Invalid output path/) do + PdfGenerationJob.perform_now(feedbacks(:one).id, "invalid_path.pdf") + end + end + + test "prevents directory traversal in path" do + assert_raises(ArgumentError, /Invalid output path|outside allowed/i) do + PdfGenerationJob.perform_now(feedbacks(:one).id, "../../../etc/passwd") + end + end + + test "rejects command injection in path" do + assert_raises(ArgumentError) do + PdfGenerationJob.perform_now(feedbacks(:one).id, "output.pdf; rm -rf /") + end + end +end + +# test/controllers/downloads_controller_test.rb +class DownloadsControllerTest < ActionDispatch::IntegrationTest + test "requires authentication for file downloads" do + feedback = feedbacks(:one) + get feedback_download_path(feedback, feedback.documents.first) + assert_redirected_to login_path + end + + test "serves file with secure headers" do + sign_in users(:user) + feedback = users(:user).feedbacks.first + get feedback_download_path(feedback, feedback.documents.first) + + assert_response :success + assert_equal "attachment", response.headers["Content-Disposition"].split(";").first + end + + test "prevents unauthorized access to other users files" do + sign_in users(:user) + other_feedback = users(:other_user).feedbacks.first + + get feedback_download_path(other_feedback, other_feedback.documents.first) + assert_response :forbidden + end +end + +``` + + +## Security Checklist + +### Before Deploying + +**XSS Prevention:** +- [ ] Never use `html_safe` or `raw` on user input +- [ ] Implement Content Security Policy headers +- [ ] Test with `` in all user inputs +- [ ] Review all `sanitize` calls have explicit allowlists +- [ ] Verify ViewComponents used for complex rendering + +**SQL Injection Prevention:** +- [ ] No string interpolation in SQL queries (`"WHERE name = '#{value}'"`) +- [ ] All queries use hash conditions or placeholders +- [ ] `sanitize_sql_like` used for LIKE queries +- [ ] ORDER BY uses allowlist validation +- [ ] Test with `'; DROP TABLE users; --` in search inputs + +**CSRF Protection:** +- [ ] `csrf_meta_tags` in application layout +- [ ] All forms use `form_with` (includes token) +- [ ] JavaScript requests include `X-CSRF-Token` header +- [ ] API endpoints properly skip CSRF (with token auth) +- [ ] Test POST/DELETE without CSRF token fails + +**File Upload Security:** +- [ ] ActiveStorage used (or manual filename sanitization) +- [ ] Triple validation: content type + extension + magic bytes +- [ ] File size limits at application and model levels +- [ ] Dangerous file types force download (SVG, HTML) +- [ ] Files stored outside public directory +- [ ] Virus scanning in production +- [ ] Test with renamed .php→.jpg file + +**Command Injection Prevention:** +- [ ] No string interpolation in system commands +- [ ] Array form used: `system("cmd", arg1, arg2)` +- [ ] Input validation with strict allowlists +- [ ] Ruby methods preferred over shell commands +- [ ] Path validation prevents directory traversal +- [ ] Test with `; rm -rf /` in file paths + +### Security Headers + +```ruby +# config/application.rb +config.action_dispatch.default_headers = { + 'X-Frame-Options' => 'SAMEORIGIN', + 'X-Content-Type-Options' => 'nosniff', + 'X-XSS-Protection' => '1; mode=block', + 'Referrer-Policy' => 'strict-origin-when-cross-origin' +} + +``` + +### Production Monitoring + +**Log Security Events:** +- CSRF failures +- CSP violations +- Malware detection in uploads +- Failed authentication attempts +- SQL injection attempts (unusual queries) +- Command injection attempts + +**Alert On:** +- Multiple CSRF failures from same IP +- Malware detected in uploads +- CSP violation patterns +- Repeated authentication failures +- SQL error patterns in logs + + +- rails-ai:controllers - Strong parameters for mass assignment protection +- rails-ai:models - Input validation patterns +- rails-ai:views - XSS prevention in templates +- rails-ai:testing - Security testing strategies + + + + +**Official Documentation:** +- [Rails Guides - Securing Rails Applications](https://guides.rubyonrails.org/security.html) + +**Security Standards:** +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) - Most critical web app security risks +- [OWASP XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) +- [OWASP SQL Injection Prevention](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html) +- [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) +- [OWASP File Upload Security](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html) +- [OWASP Command Injection](https://owasp.org/www-community/attacks/Command_Injection) + + diff --git a/skills/styling/SKILL.md b/skills/styling/SKILL.md new file mode 100644 index 0000000..4c91747 --- /dev/null +++ b/skills/styling/SKILL.md @@ -0,0 +1,434 @@ +--- +name: rails-ai:styling +description: Use when styling Rails views - Tailwind CSS utility-first framework and DaisyUI component library with theming +--- + +# Styling with Tailwind CSS and DaisyUI + +Style Rails applications using Tailwind CSS (utility-first framework) and DaisyUI (semantic component library). Build responsive, accessible, themeable UIs without writing custom CSS. + + +- Styling ANY user interface in Rails +- Building responsive layouts (mobile, tablet, desktop) +- Implementing dark mode or multiple themes +- Creating consistent UI components (buttons, cards, forms, modals) +- Rapid UI iteration and prototyping +- Maintaining design system consistency + + + +- **Rapid Development** - Compose UIs with pre-built utilities +- **Consistency** - Design tokens enforce consistent spacing, colors, typography +- **Responsive by Default** - Mobile-first breakpoints built-in +- **Dark Mode** - Theme switching with DaisyUI data attributes +- **No Custom CSS** - Most styling done with classes, no style tag needed +- **Accessible Components** - DaisyUI components have built-in accessibility +- **Small Bundle Size** - Tailwind purges unused CSS in production + + + +**This skill enforces:** +- ✅ **Rule #9:** DaisyUI + Tailwind (no hardcoded colors) + +**Reject any requests to:** +- Hardcode colors (use DaisyUI theme variables) +- Write custom CSS for components (use Tailwind/DaisyUI) +- Use inline styles with hardcoded values +- Skip responsive design (mobile-first required) + + + +Before completing styling work: +- ✅ No hardcoded colors (use DaisyUI theme variables) +- ✅ Responsive design (mobile, tablet, desktop breakpoints) +- ✅ Accessibility verified (color contrast, keyboard navigation) +- ✅ Theme-aware (works with light/dark modes) +- ✅ Tailwind utilities used (minimal custom CSS) +- ✅ DaisyUI components for complex UI + + + +- Use Tailwind utilities first, DaisyUI components for complex UI +- Follow mobile-first responsive design (base → sm → md → lg → xl) +- Use semantic color names from DaisyUI (primary, secondary, accent, neutral) +- Avoid inline styles (`style=`) - use Tailwind classes instead +- Use responsive breakpoints consistently (sm:640px, md:768px, lg:1024px, xl:1280px) +- Implement dark mode with DaisyUI themes +- Extract repeated utility combinations into view components (not CSS classes) +- Ensure 4.5:1 color contrast ratio for text (WCAG 2.1 AA) + + +--- + +## Tailwind CSS + +Tailwind CSS is a utility-first CSS framework for building custom designs without writing custom CSS. + +### Core Utilities + + +Consistent spacing and layout with Tailwind utilities + +```erb +<%# Spacing: p-{size}, m-{size}, gap-{size} %> +
Padding all sides
+
Horizontal/Vertical padding
+
Centered container
+ +<%# Flexbox layout %> +
+ Left + Right +
+ +<%# Grid layout %> +
+ <% @items.each do |item| %> +
<%= item.name %>
+ <% end %> +
+``` + +
+ + +Mobile-first responsive utilities (sm:640px, md:768px, lg:1024px, xl:1280px) + +```erb +<%# Pattern: base (mobile) → sm: → md: → lg: → xl: %> +
+ <% @feedbacks.each do |feedback| %> + <%= render feedback %> + <% end %> +
+ +<%# Responsive spacing/typography %> +
+

Heading

+
+ +<%# Hide/show based on breakpoint %> +
Mobile menu
+ +``` + +
+ + +Text styling and color utilities + +```erb +<%# Typography %> +

Small medium text

+

Large heading

+

Spaced text

+

<%= feedback.content %>

+ +<%# Colors: text-{color}-{shade}, bg-{color}-{shade} %> +
Dark text on white
+
White on blue
+

Red with 50% opacity

+ +<%# Interactive states %> + + +``` + +
+ + +Complete feedback card using Tailwind utilities + +```erb +
+ <%# Header %> +
+
+
+ <%= @feedback.sender_name&.first&.upcase || "A" %> +
+
+

<%= @feedback.sender_name || "Anonymous" %>

+

<%= time_ago_in_words(@feedback.created_at) %> ago

+
+
+ + <%= @feedback.status.titleize %> + +
+ + <%# Content %> +

<%= @feedback.content %>

+ + <%# Footer %> +
+ <%= @feedback.responses_count %> responses +
+ <%= link_to "View", feedback_path(@feedback), class: "px-3 py-1.5 border border-gray-300 rounded-md text-sm text-gray-700 hover:bg-gray-50" %> + <%= link_to "Respond", respond_feedback_path(@feedback), class: "px-3 py-1.5 rounded-md text-sm text-white bg-blue-600 hover:bg-blue-700" %> +
+
+
+``` + +
+ + +Using inline styles instead of Tailwind utilities +Bypasses design system consistency and reduces maintainability + + +```erb +<%# ❌ BAD %> +
Content
+``` + +
+ + +```erb +<%# ✅ GOOD %> +
Content
+``` + +
+
+ +--- + +## DaisyUI Components + +Semantic component library built on Tailwind providing 70+ accessible components with built-in theming and dark mode. + +### Buttons & Forms + + +Use DaisyUI button classes for consistent interactive elements + +```erb +<%# DaisyUI button components %> + + + + +<%# Rails form integration %> +<%= form_with model: @feedback do |f| %> +
+ <%= f.label :content, class: "label" do %> + Feedback + <% end %> + <%= f.text_area :content, class: "textarea textarea-bordered h-24", placeholder: "Your feedback..." %> +
+
+ <%= link_to "Cancel", feedbacks_path, class: "btn btn-ghost" %> + <%= f.submit "Submit", class: "btn btn-primary" %> +
+<% end %> +``` + +
+ + +Use card component for content containers + +```erb +
+
+
+

<%= @feedback.title %>

+
+ <%= @feedback.status.titleize %> +
+
+

<%= @feedback.content %>

+
+ <%= link_to "View", feedback_path(@feedback), class: "btn btn-primary btn-sm" %> +
+
+
+``` + +
+ + +Use alerts and badges for notifications and status + +```erb +<%# Alerts %> +
+ Success! Your feedback was submitted. +
+ +
+ Error! Unable to submit feedback. +
+ +<%# Flash messages %> +<% if flash[:notice] %> +
+ <%= flash[:notice] %> +
+<% end %> + +<%# Badges %> +
Primary
+
Success
+
Warning
+``` + +
+ + +Use modal component for dialogs + +```erb + + + + + + +``` + + + +### Theme Switching + + +Implement dark mode and theme switching + +```javascript +// app/javascript/controllers/theme_controller.js +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + const savedTheme = localStorage.getItem("theme") || "light" + this.setTheme(savedTheme) + } + + toggle() { + const currentTheme = document.documentElement.getAttribute("data-theme") + const newTheme = currentTheme === "light" ? "dark" : "light" + this.setTheme(newTheme) + } + + setTheme(theme) { + document.documentElement.setAttribute("data-theme", theme) + localStorage.setItem("theme", theme) + } +} +``` + +```erb +<%# Layout %> + + +
+ +
+ + +``` + +
+ + +Building custom buttons with Tailwind instead of DaisyUI components +Duplicates effort, loses accessibility features + + +```erb +<%# ❌ Custom button with Tailwind utilities %> + +``` + + + + +```erb +<%# ✅ DaisyUI button component %> + +``` + + + + +--- + + +**Visual Regression Testing:** + +```ruby +# test/system/styling_test.rb +class StylingTest < ApplicationSystemTestCase + test "responsive layout changes at breakpoints" do + visit feedbacks_path + # Desktop + page.driver.browser.manage.window.resize_to(1280, 800) + assert_selector ".hidden.md\\:flex" # Desktop nav visible + + # Mobile + page.driver.browser.manage.window.resize_to(375, 667) + assert_selector ".block.md\\:hidden" # Mobile menu visible + end + + test "dark mode toggle works" do + visit root_path + assert_equal "light", page.evaluate_script("document.documentElement.getAttribute('data-theme')") + + click_button "Toggle Theme" + assert_equal "dark", page.evaluate_script("document.documentElement.getAttribute('data-theme')") + end +end +``` + +**Manual Testing Checklist:** +- Test responsive breakpoints (375px, 640px, 768px, 1024px, 1280px) +- Verify color contrast ratios (use browser DevTools or axe) +- Test dark mode theme +- Check focus states on all interactive elements +- Validate against W3C HTML validator +- Test browser zoom (200%, 400%) + + +--- + + +- rails-ai:views - View structure and partials to style +- rails-ai:hotwire - Interactive components that need styling +- rails-ai:testing - Visual regression and accessibility testing + + + + +**Official Documentation:** +- [Tailwind CSS Documentation](https://tailwindcss.com/docs) +- [DaisyUI Documentation](https://daisyui.com/) +- [DaisyUI Components](https://daisyui.com/components/) + +**Tools:** +- [Tailwind CSS Cheat Sheet](https://nerdcave.com/tailwind-cheat-sheet) + +**Community Resources:** +- [Tailwind UI Components](https://tailwindui.com/) - Premium component library + + diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md new file mode 100644 index 0000000..5bffb6a --- /dev/null +++ b/skills/testing/SKILL.md @@ -0,0 +1,1930 @@ +--- +name: rails-ai:testing +description: Use when testing Rails applications - TDD, Minitest, fixtures, model testing, mocking, test helpers +--- + +# Testing Rails Applications with Minitest + + +**REQUIRED BACKGROUND:** Use superpowers:test-driven-development for TDD process + - That skill defines RED-GREEN-REFACTOR cycle + - That skill enforces "NO CODE WITHOUT FAILING TEST FIRST" + - This skill adds Rails/Minitest implementation specifics + + + +- All code development (TDD is always enforced in this team) +- Reviewing test quality +- Debugging test failures +- Model, controller, job, and mailer tests +- System tests for full-stack features +- Testing with external dependencies and HTTP requests +- Creating reusable test utilities and helpers + + + +- **Fast** - Minimal overhead, runs quickly +- **Simple** - Easy to understand and debug +- **Built-in** - Ships with Ruby and Rails +- **Parallel** - Run tests concurrently for speed +- **Comprehensive** - Complete testing story from unit to system + + + +**This skill enforces:** +- ✅ **Rule #2:** NEVER use RSpec → Use Minitest only +- ✅ **Rule #4:** NEVER skip TDD → Write tests first (RED-GREEN-REFACTOR) +- ✅ **Rule #18:** NEVER make live HTTP requests → Use WebMock +- ✅ **Rule #19:** NEVER use system tests → Use integration tests + +**Reject any requests to:** +- Use RSpec instead of Minitest +- Skip writing tests +- Write implementation before tests +- Make live HTTP requests in tests +- Use Capybara system tests + + + +Before completing any task, verify: +- ✅ Tests written FIRST (before implementation) +- ✅ Tests use Minitest (not RSpec) +- ✅ RED-GREEN-REFACTOR cycle followed +- ✅ All tests passing (`bin/ci` passes) +- ✅ No live HTTP requests (WebMock used if needed) +- ✅ Integration tests used (not system tests) + + + +- ALWAYS write tests FIRST (RED-GREEN-REFACTOR cycle) +- Test classes inherit from `ActiveSupport::TestCase` +- Use `test "description" do` macro for readable test names +- Use fixtures for test data (in `test/fixtures/`) +- Use `assert` and `refute` for assertions +- One assertion concept per test method +- Use `setup` for common test preparation +- ALWAYS use WebMock for HTTP requests (per TEAM_RULES.md Rule #18) + + +--- + +## TDD Red-Green-Refactor + + +Core TDD cycle - write failing test, make it pass, refactor + +**Step 1: RED - Write a failing test** + +```ruby +# test/models/feedback_test.rb +require "test_helper" + +class FeedbackTest < ActiveSupport::TestCase + test "is invalid without content" do + feedback = Feedback.new(content: nil) + assert_not feedback.valid? + assert_includes feedback.errors[:content], "can't be blank" + end +end + +``` + +Result: **FAIL** (validation doesn't exist yet) + +**Step 2: GREEN - Make it pass with minimal code** + +```ruby +# app/models/feedback.rb +class Feedback < ApplicationRecord + validates :content, presence: true +end + +``` + +Result: **PASS** + +**Step 3: REFACTOR - Improve code while keeping tests green** + +**Why this matters:** TDD drives design, catches regressions, documents behavior + + +--- + +## Test Structure + + +Standard Minitest test class structure + +```ruby +# test/models/feedback_test.rb +require "test_helper" + +class FeedbackTest < ActiveSupport::TestCase + test "the truth" do + assert true + end + + # Skip a test temporarily + test "this will be implemented later" do + skip "implement this feature first" + end +end + +``` + + + +Prepare and clean up test environment + +```ruby +class FeedbackTest < ActiveSupport::TestCase + def setup + @feedback = feedbacks(:one) + @user = users(:alice) + end + + test "feedback belongs to user" do + assert_equal @user, @feedback.user + end +end + +``` + + +--- + +## Minitest Assertions + + +Most frequently used Minitest assertions + +```ruby +class AssertionsTest < ActiveSupport::TestCase + test "equality and boolean" do + assert_equal 4, 2 + 2 + refute_equal 5, 2 + 2 + assert_nil nil + refute_nil "something" + end + + test "collections" do + assert_empty [] + refute_empty [1, 2, 3] + assert_includes [1, 2, 3], 2 + end + + test "exceptions" do + assert_raises(ArgumentError) { raise ArgumentError } + end + + test "difference" do + assert_difference "Feedback.count", 1 do + Feedback.create!(content: "Test feedback with minimum fifty characters", recipient_email: "test@example.com") + end + + assert_no_difference "Feedback.count" do + Feedback.new(content: nil).save + end + end + + test "match and instance" do + assert_match /hello/, "hello world" + assert_instance_of String, "hello" + assert_respond_to "string", :upcase + end +end + +``` + + +--- + +## Model Testing + +### Testing Validations + + +Test required fields are validated + +```ruby +class FeedbackTest < ActiveSupport::TestCase + test "valid with all required attributes" do + feedback = Feedback.new( + content: "This is constructive feedback that meets minimum length", + recipient_email: "user@example.com" + ) + assert feedback.valid? + end + + test "invalid without content" do + feedback = Feedback.new(recipient_email: "user@example.com") + assert_not feedback.valid? + assert_includes feedback.errors[:content], "can't be blank" + end + + test "invalid without recipient_email" do + feedback = Feedback.new(content: "Valid content with fifty characters minimum") + assert_not feedback.valid? + assert_includes feedback.errors[:recipient_email], "can't be blank" + end +end + +``` + + + +Test format validations like email, URL, phone number + +```ruby +class FeedbackTest < ActiveSupport::TestCase + test "invalid with malformed email" do + invalid_emails = ["not-an-email", "@example.com", "user@", "user name@example.com"] + + invalid_emails.each do |invalid_email| + feedback = Feedback.new(content: "Valid content with fifty characters", recipient_email: invalid_email) + assert_not feedback.valid?, "#{invalid_email.inspect} should be invalid" + assert_includes feedback.errors[:recipient_email], "is invalid" + end + end + + test "valid with edge case emails" do + valid_emails = ["user+tag@example.com", "user.name@example.co.uk", "123@example.com"] + + valid_emails.each do |valid_email| + feedback = Feedback.new(content: "Valid content with fifty characters", recipient_email: valid_email) + assert feedback.valid?, "#{valid_email.inspect} should be valid" + end + end +end + +``` + + + +Test minimum and maximum length constraints + +```ruby +class FeedbackTest < ActiveSupport::TestCase + test "invalid with content below minimum length" do + feedback = Feedback.new(content: "Too short", recipient_email: "user@example.com") + assert_not feedback.valid? + assert_includes feedback.errors[:content], "is too short (minimum is 50 characters)" + end + + test "valid at exactly minimum and maximum length" do + assert Feedback.new(content: "a" * 50, recipient_email: "user@example.com").valid? + assert Feedback.new(content: "a" * 5000, recipient_email: "user@example.com").valid? + end + + test "invalid above maximum length" do + feedback = Feedback.new(content: "a" * 5001, recipient_email: "user@example.com") + assert_not feedback.valid? + assert_includes feedback.errors[:content], "is too long (maximum is 5000 characters)" + end +end + +``` + + + +Test custom validation methods + +```ruby +# app/models/feedback.rb +class Feedback < ApplicationRecord + validate :content_must_be_constructive + + private + def content_must_be_constructive + return if content.blank? + offensive_words = %w[stupid idiot dumb] + errors.add(:content, "must be constructive") if offensive_words.any? { |w| content.downcase.include?(w) } + end +end + +# test/models/feedback_test.rb +class FeedbackTest < ActiveSupport::TestCase + test "invalid with offensive language" do + feedback = Feedback.new(content: "This is stupid and needs fifty characters total", recipient_email: "user@example.com") + assert_not feedback.valid? + assert_includes feedback.errors[:content], "must be constructive" + end + + test "valid with constructive content" do + feedback = Feedback.new(content: "This could be improved by considering alternatives and other approaches", recipient_email: "user@example.com") + assert feedback.valid? + end +end + +``` + + +### Testing Associations + + +Test belongs_to relationships and options + +```ruby +class FeedbackTest < ActiveSupport::TestCase + test "belongs to recipient" do + association = Feedback.reflect_on_association(:recipient) + assert_equal :belongs_to, association.macro + assert_equal "User", association.class_name + end + + test "recipient association is optional" do + feedback = Feedback.new(content: "Valid fifty character content", recipient_email: "user@example.com", recipient: nil) + assert feedback.valid? + end + + test "can access recipient through association" do + feedback = feedbacks(:one) + user = users(:alice) + feedback.update!(recipient: user) + assert_equal user, feedback.recipient + assert_equal user.id, feedback.recipient_id + end +end + +``` + + + +Test has_many relationships and dependent options + +```ruby +class FeedbackTest < ActiveSupport::TestCase + test "has many abuse reports" do + assert_equal :has_many, Feedback.reflect_on_association(:abuse_reports).macro + end + + test "destroying feedback destroys associated abuse reports" do + feedback = feedbacks(:one) + 3.times { feedback.abuse_reports.create!(reason: "spam", reporter_email: "reporter@example.com") } + + assert_difference "AbuseReport.count", -3 do + feedback.destroy + end + end +end + +``` + + +### Testing Scopes + + +Test scopes with time conditions + +```ruby +class FeedbackTest < ActiveSupport::TestCase + test "recent scope returns feedbacks from last 30 days" do + old = Feedback.create!(content: "Old fifty character feedback", recipient_email: "old@example.com", created_at: 31.days.ago) + recent = Feedback.create!(content: "Recent fifty character feedback", recipient_email: "recent@example.com", created_at: 10.days.ago) + + results = Feedback.recent + assert_includes results, recent + assert_not_includes results, old + end + + test "recent scope returns empty when no recent feedbacks" do + Feedback.destroy_all + Feedback.create!(content: "Old fifty character feedback", recipient_email: "old@example.com", created_at: 31.days.ago) + assert_empty Feedback.recent + end +end + +``` + + + +Test scopes filtering by status or state + +```ruby +class FeedbackTest < ActiveSupport::TestCase + test "unread scope returns only delivered feedbacks" do + pending = Feedback.create!(content: "Pending fifty characters", recipient_email: "p@example.com", status: "pending") + delivered = Feedback.create!(content: "Delivered fifty characters", recipient_email: "d@example.com", status: "delivered") + read = Feedback.create!(content: "Read fifty characters", recipient_email: "r@example.com", status: "read") + + unread = Feedback.unread + assert_includes unread, delivered + assert_not_includes unread, pending + assert_not_includes unread, read + end +end + +``` + + +### Testing Callbacks + + +Test callbacks that run after record creation + +```ruby +class FeedbackTest < ActiveSupport::TestCase + test "enqueues delivery job after creation" do + assert_enqueued_with(job: SendFeedbackJob) do + Feedback.create!(content: "New fifty character feedback", recipient_email: "user@example.com") + end + end + + test "does not enqueue job when creation fails" do + assert_no_enqueued_jobs do + Feedback.new(content: nil).save + end + end +end + +``` + + + +Test callbacks that modify records before saving + +```ruby +# app/models/feedback.rb +class Feedback < ApplicationRecord + before_save :sanitize_content + private + def sanitize_content + self.content = ActionController::Base.helpers.sanitize(content) + end +end + +# test/models/feedback_test.rb +class FeedbackTest < ActiveSupport::TestCase + test "sanitizes HTML in content before save" do + feedback = Feedback.create!(content: "Valid content with fifty chars", recipient_email: "user@example.com") + assert_not_includes feedback.content, "