diff --git a/.claude/agents/rails-backend.md b/.claude/agents/rails-backend.md index c367f74..48d884f 100644 --- a/.claude/agents/rails-backend.md +++ b/.claude/agents/rails-backend.md @@ -113,5 +113,5 @@ Before completing any task: ```bash bundle exec rubocop -A # Fix lint issues rails test # Run tests -bundle exec brakeman -q # Security check +bin/brakeman --no-pager # Security check ``` diff --git a/.claude/commands/commit-push-pr.md b/.claude/commands/commit-push-pr.md index ec863aa..60d740e 100644 --- a/.claude/commands/commit-push-pr.md +++ b/.claude/commands/commit-push-pr.md @@ -21,7 +21,7 @@ Before committing, ensure code quality: ```bash bundle exec rubocop -A rails test -bundle exec brakeman -q --no-pager +bin/brakeman --no-pager ``` If any quality gate fails, fix the issues before proceeding. diff --git a/.claude/commands/review-code.md b/.claude/commands/review-code.md index e98a851..1a627e4 100644 --- a/.claude/commands/review-code.md +++ b/.claude/commands/review-code.md @@ -65,7 +65,7 @@ git diff --cached # If already staged ```bash bundle exec rubocop -A rails test -bundle exec brakeman -q --no-pager +bin/brakeman --no-pager ``` ## 4. Common Issues diff --git a/.claude/commands/run-tests.md b/.claude/commands/run-tests.md index af59205..96db593 100644 --- a/.claude/commands/run-tests.md +++ b/.claude/commands/run-tests.md @@ -93,7 +93,7 @@ Action: Method doesn't exist, check model implementation. If all tests pass: ```bash bundle exec rubocop -A -bundle exec brakeman -q +bin/brakeman --no-pager ``` If tests fail: diff --git a/.claude/rules/i18n.md b/.claude/rules/i18n.md new file mode 100644 index 0000000..e58769e --- /dev/null +++ b/.claude/rules/i18n.md @@ -0,0 +1,168 @@ +# i18n Standards + +## Philosophy + +**No hardcoded user-facing text.** All strings shown to users must use Rails i18n. + +## Key Conventions + +### Lazy Lookup in Views + +Use the shorthand `.key` notation in views - Rails automatically infers the full path: + +```erb +<%# In app/views/sessions/new.html.erb %> +<%= t(".heading") %> <%# Looks up en.sessions.new.heading %> +<%= t(".email_label") %> +``` + +### Lazy Lookup in Mailers + +Same pattern works in mailers: + +```ruby +# In app/mailers/user_mailer.rb, method magic_link +mail(to: user.email, subject: t(".subject")) # en.user_mailer.magic_link.subject +``` + +### Controllers Use Full Paths + +Controllers should use explicit paths for clarity: + +```ruby +redirect_to path, notice: t("controllers.sessions.create.notice") +redirect_to path, alert: t("controllers.application.authenticate_user") +``` + +### Interpolation + +Use `%{variable}` for dynamic values: + +```yaml +# config/locales/en/controllers.yml +en: + controllers: + sessions: + verify: + notice: "Welcome back, %{name}!" +``` + +```ruby +t("controllers.sessions.verify.notice", name: user.name) +``` + +## File Structure + +``` +config/locales/ +├── en.yml # Common terms (app_name, common.*) +└── en/ + ├── controllers.yml # Flash messages and controller strings + ├── mailers.yml # Email subjects and content + └── views/ + ├── sessions.yml # User login views + ├── home.yml # Dashboard + ├── chats.yml # Chat views + ├── models.yml # AI models views + ├── messages.yml # Message form + ├── shared.yml # Sidebar, flash, shared partials + └── admins/ + └── sessions.yml # Admin login views +``` + +## What to Translate + +| Always Translate | Don't Translate | +|-----------------|-----------------| +| Headings and titles | HTML attributes (class, id, data-*) | +| Button labels | Code paths and URLs | +| Form labels and placeholders | Technical identifiers | +| Error and success messages | Database values | +| Help text and descriptions | CSS values | +| Navigation labels | File names in code blocks | + +## Quality Checks + +Run before committing: + +```bash +bundle exec i18n-tasks health # Full health check (in CI) +bundle exec i18n-tasks missing # Find missing translations +bundle exec i18n-tasks unused # Find unused translations +``` + +## Adding New Translations + +1. **Add the key to the appropriate locale file** following the file structure +2. **Use lazy lookup** (`.key`) in views and mailers where possible +3. **Use full paths** in controllers for clarity +4. **Run `i18n-tasks health`** to verify no missing or unused keys + +## Examples + +### View with Lazy Lookup + +```erb +<%# app/views/users/show.html.erb %> +

<%= t(".heading") %>

+

<%= t(".description", name: @user.name) %>

+``` + +```yaml +# config/locales/en/views/users.yml +en: + users: + show: + heading: "User Profile" + description: "Welcome, %{name}!" +``` + +### Controller Flash Message + +```ruby +# app/controllers/users_controller.rb +def create + @user = User.create!(user_params) + redirect_to @user, notice: t("controllers.users.create.notice") +end +``` + +```yaml +# config/locales/en/controllers.yml +en: + controllers: + users: + create: + notice: "User created successfully" +``` + +### Form Labels + +```erb +<%= form.label :email, t(".email_label") %> +<%= form.text_field :email, placeholder: t(".email_placeholder") %> +``` + +## Common Patterns + +### Pluralization + +```yaml +en: + messages: + count: + one: "1 message" + other: "%{count} messages" +``` + +```ruby +t("messages.count", count: @messages.size) +``` + +### Defaults + +When a key might not exist, provide a default: + +```erb +<%= t(".title", default: "Default Title") %> +``` diff --git a/.claude/rules/madmin.md b/.claude/rules/madmin.md new file mode 100644 index 0000000..c4c8ae2 --- /dev/null +++ b/.claude/rules/madmin.md @@ -0,0 +1,149 @@ +--- +description: Madmin admin panel conventions +globs: ["app/madmin/**/*.rb", "app/controllers/madmin/**/*.rb"] +--- + +# Madmin Standards + +## Overview + +Madmin is the admin panel at `/madmin`. All admin CRUD operations go through Madmin resources. + +## Creating Resources + +```bash +rails generate madmin:resource ModelName +``` + +This creates `app/madmin/resources/model_name_resource.rb` + +## Resource Configuration + +```ruby +# app/madmin/resources/user_resource.rb +class UserResource < Madmin::Resource + # Attributes displayed in admin + attribute :id, form: false + attribute :email + attribute :name + attribute :created_at, form: false + attribute :updated_at, form: false + + # Customize index columns + def self.index_attributes + [:id, :email, :name, :created_at] + end + + # Customize form fields + def self.form_attributes + [:email, :name] + end + + # Customize show page + def self.show_attributes + [:id, :email, :name, :created_at, :updated_at] + end + + # Scopes for filtering + def self.scopes + [ + Madmin::Scope.new(:all), + Madmin::Scope.new(:recent, ->(resources) { resources.where("created_at > ?", 1.week.ago) }) + ] + end +end +``` + +## Custom Fields + +Create custom fields in `app/madmin/fields/`: + +```ruby +# app/madmin/fields/json_field.rb +class JsonField < Madmin::Field + def to_s + JSON.pretty_generate(value) if value.present? + end +end +``` + +Usage: +```ruby +attribute :metadata, field: "JsonField" +``` + +## Custom Actions + +Add member actions to resources: + +```ruby +class AdminResource < Madmin::Resource + member_action :send_magic_link, method: :post do + admin = Admin.find(params[:id]) + AdminMailer.magic_link(admin).deliver_later + redirect_to madmin_admin_path(admin), notice: "Magic link sent!" + end +end +``` + +## Authentication + +```ruby +# app/controllers/madmin/application_controller.rb +class Madmin::ApplicationController < Madmin::BaseController + before_action :authenticate_admin! + + private + + def authenticate_admin! + redirect_to main_app.new_admins_session_path unless current_admin + end + + def current_admin + @current_admin ||= Admin.find_by(id: session[:admin_id]) if session[:admin_id] + end + helper_method :current_admin +end +``` + +## Customizing Views + +Generate views to customize: + +```bash +rails generate madmin:views +rails generate madmin:views users # For specific resource +``` + +Views go in `app/views/madmin/` + +## Sortable Columns (STRICT) + +**Every column in a Madmin index table MUST be sortable.** No exceptions. + +- Add the column name to `self.sortable_columns` in the resource +- Use `<%= sortable :column_name, "Label" %>` in the `` (never plain `Text`) +- For computed columns (counts, sums), add custom sort logic in the controller's `scoped_resources` + +```ruby +# Resource +def self.sortable_columns + super + %w[owner_name members_count chats_count total_cost] +end + +# Controller - custom sort for computed columns +when "chats_count" + resources.left_joins(:chats).group("teams.id") + .reorder(Arel.sql("COUNT(chats.id) #{sort_direction}")) +when "total_cost" + resources.left_joins(:chats).group("teams.id") + .reorder(Arel.sql("COALESCE(SUM(chats.total_cost), 0) #{sort_direction}")) +``` + +## Interface Separation + +**IMPORTANT:** Keep admin and user interfaces completely separate: +- User interface: `/session/new`, `/home`, etc. +- Admin interface: `/admins/session/new` (login), `/madmin` (panel) +- **No links between user and admin interfaces** +- Admin login is separate (different URLs, different styling) diff --git a/.claude/rules/migrations.md b/.claude/rules/migrations.md new file mode 100644 index 0000000..99d5c9a --- /dev/null +++ b/.claude/rules/migrations.md @@ -0,0 +1,169 @@ +--- +description: Database migration conventions +globs: ["db/migrate/**/*.rb"] +--- + +# Migration Standards + +## UUIDv7 Primary Keys + +All tables use UUIDv7 string primary keys with database-level default: + +```ruby +class CreateCards < ActiveRecord::Migration[8.0] + def change + create_table :cards, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.string :title, null: false + t.timestamps + end + end +end +``` + +**Key points:** +- `id: { type: :string, default: -> { "uuid7()" } }` - String PK with SQLite-generated UUIDv7 +- `force: true` - Drop table if exists (optional, useful in dev) +- No model callback needed - database handles ID generation + +## Foreign Keys + +Reference other tables with string type and foreign key constraints: + +```ruby +create_table :comments, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.references :card, null: false, foreign_key: true, type: :string + t.references :author, null: false, foreign_key: { to_table: :users }, type: :string + t.text :body, null: false + t.timestamps +end +``` + +## Constraints Over Validations + +Prefer database-level constraints: + +```ruby +# ✅ GOOD: Database constraint +t.string :email, null: false +add_index :users, :email, unique: true + +# ❌ AVOID: Model-only validation (use both if needed) +validates :email, presence: true, uniqueness: true +``` + +## Common Patterns + +### State Records (not boolean columns) + +```ruby +# ✅ GOOD: State as separate table +create_table :closures, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.references :closeable, polymorphic: true, null: false, type: :string + t.references :closed_by, null: false, foreign_key: { to_table: :users }, type: :string + t.timestamps +end + +# ❌ AVOID: Boolean column +add_column :cards, :closed, :boolean, default: false +``` + +### Timestamps + +Always include timestamps: + +```ruby +create_table :cards, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + # columns... + t.timestamps # adds created_at and updated_at +end +``` + +### Indexes + +Add indexes for: +- Foreign keys (automatic with `references`) +- Columns used in WHERE clauses +- Columns used in ORDER BY +- Unique constraints + +```ruby +add_index :cards, :board_id +add_index :cards, [:board_id, :created_at] +add_index :cards, :title, where: "deleted_at IS NULL" +``` + +## Reversible Migrations + +Make migrations reversible when possible: + +```ruby +class AddDescriptionToCards < ActiveRecord::Migration[8.0] + def change + add_column :cards, :description, :text # Reversible + end +end + +# For complex changes, use up/down +class ComplexMigration < ActiveRecord::Migration[8.0] + def up + # Forward migration + end + + def down + # Rollback + end +end +``` + +## Data Migrations + +Separate data migrations from schema migrations: + +```ruby +# db/migrate/20240101_add_status_to_cards.rb +class AddStatusToCards < ActiveRecord::Migration[8.0] + def change + add_column :cards, :status, :string, default: "draft" + end +end + +# lib/tasks/data_migrations.rake +namespace :data do + desc "Backfill card statuses" + task backfill_card_statuses: :environment do + Card.where(status: nil).update_all(status: "draft") + end +end +``` + +## SQLite Specifics + +SQLite has some limitations: + +```ruby +# ❌ Cannot change column in SQLite easily +change_column :cards, :title, :text + +# ✅ Create new table and migrate data +class ChangeCardTitleToText < ActiveRecord::Migration[8.0] + def up + rename_table :cards, :cards_old + create_table :cards, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.text :title # Changed from string + t.timestamps + end + execute "INSERT INTO cards SELECT * FROM cards_old" + drop_table :cards_old + end +end +``` + +## Running Migrations + +```bash +rails db:migrate # Run pending migrations +rails db:migrate:status # Show migration status +rails db:rollback # Rollback last migration +rails db:rollback STEP=3 # Rollback 3 migrations +rails db:migrate VERSION=0 # Rollback all +``` \ No newline at end of file diff --git a/.claude/rules/multilingual.md b/.claude/rules/multilingual.md new file mode 100644 index 0000000..1e235ab --- /dev/null +++ b/.claude/rules/multilingual.md @@ -0,0 +1,87 @@ +# Multilingual Content Standards + +## Overview + +User-generated content is automatically translated via LLM when teams enable multiple languages. The Mobility gem stores translations; RubyLLM handles the actual translation. + +## Making a Model Translatable + +1. Include the `Translatable` concern +2. Call `translatable` for each attribute with its type (`:string` or `:text`) +3. The model must `belong_to :team` (translations are team-scoped) + +```ruby +class Article < ApplicationRecord + include Translatable + belongs_to :team + belongs_to :user + translatable :title, type: :string + translatable :body, type: :text +end +``` + +## How It Works + +### Storage: Mobility KeyValue Backend + +Translations are stored in two shared polymorphic tables: +- `mobility_string_translations` - for string columns +- `mobility_text_translations` - for text columns + +No per-model migration needed. You specify the type explicitly via the `type:` keyword. + +### Translation Flow + +1. Record is created/updated with translatable attribute changes +2. `after_commit` callback checks `previous_changes` for translatable attributes +3. Gets team's `translation_target_codes(exclude: source_locale)` +4. Bulk-enqueues `TranslateContentJob` via `perform_all_later` +5. Job calls `RubyLLM.chat(model: "gpt-4.1-nano")` with JSON prompt +6. Saves translations via `Mobility.with_locale(target_locale)` with callbacks skipped + +### Reading Translations + +```ruby +# Default: reads current I18n.locale, falls back to :en +article.title # => "Hello" (if locale is :en or no translation) + +# Explicit locale +Mobility.with_locale(:es) { article.title } # => "Hola" +``` + +### Preventing Infinite Loops + +Set `skip_translation_callbacks = true` before saving translations: + +```ruby +record.skip_translation_callbacks = true +Mobility.with_locale(:es) { record.update!(title: "Manual translation") } +record.skip_translation_callbacks = false +``` + +## Team Language Management + +- Teams have languages via `TeamLanguage` join model +- English is always required (cannot be disabled) +- Adding a language triggers `BackfillTranslationsJob` for existing content +- Removing a language soft-disables (sets `active: false`), preserving translations + +## Admin Language Management + +- Languages are managed globally via Madmin at `/madmin/languages` +- Admin can enable/disable languages (except English) +- `Language.enabled_codes` is cached for 5 minutes + +## Locale Detection + +- `Accept-Language` header is parsed in `ApplicationController` +- Matched against `Language.enabled_codes` +- Sets `I18n.locale` for the request (UI translations) +- `detected_locale` helper available in views + +## Adding a New Translatable Model + +1. Create migration with `team_id` foreign key +2. Include `Translatable` and call `translatable :attr, type: :string` (or `:text`) +3. Add MCP tools for parity +4. Write tests (concern handles translation queueing automatically) diff --git a/.claude/rules/performance.md b/.claude/rules/performance.md new file mode 100644 index 0000000..46d283d --- /dev/null +++ b/.claude/rules/performance.md @@ -0,0 +1,170 @@ +# Performance Standards + +## Philosophy + +**Every query counts.** Avoid N+1 queries, unnecessary object instantiation, and redundant iterations. Think about what SQL will be generated before writing ActiveRecord code. + +## Eager Loading + +Always use `includes` when accessing associations in loops: + +```ruby +# ❌ BAD: N+1 queries (1 query for chats + N queries for messages) +@chats = current_user.chats +@chats.each { |chat| chat.messages.first } + +# ✅ GOOD: 2 queries total +@chats = current_user.chats.includes(:messages) +@chats.each { |chat| chat.messages.first } +``` + +When rendering views that access associations, add `includes` in the controller: + +```ruby +# Controller +def index + @chats = current_user.chats.includes(:model, :messages).recent +end + +def show + @chat = current_user.chats.includes(messages: [:attachments, :tool_calls]).find(params[:id]) +end +``` + +## Counter Caches Over .count + +When a counter cache column exists, use it instead of `.count`: + +```erb +<%# ❌ BAD: Triggers COUNT query %> +<%= chat.messages.count %> + +<%# ✅ GOOD: Reads cached column (zero queries) %> +<%= chat.messages_count %> +``` + +Also use counter cache for existence checks: + +```erb +<%# ❌ BAD: Triggers query %> +<% if chat.messages.any? %> + +<%# ✅ GOOD: Reads cached column %> +<% if chat.messages_count > 0 %> +``` + +## Collection Rendering + +Use collection rendering instead of loops with `render`: + +```erb +<%# ❌ BAD: N partial lookups + N instrumentations %> +<% @messages.each do |message| %> + <%= render message %> +<% end %> + +<%# ✅ GOOD: 1 partial lookup + 1 instrumentation (~2x faster) %> +<%= render partial: "messages/message", collection: @messages, as: :message %> + +<%# ✅ GOOD: With caching (~1.7x faster on cache hits) %> +<%= render partial: "messages/message", collection: @messages, as: :message, cached: true %> +``` + +## Ruby Methods on Preloaded Collections + +When associations are eager loaded, use Ruby methods instead of ActiveRecord queries to avoid extra SQL: + +```ruby +# ❌ BAD: Triggers SQL even though messages are preloaded +chat.messages.order(:created_at).first +chat.messages.find_by(role: "user") +chat.messages.sum(:input_tokens) + +# ✅ GOOD: Uses Ruby on the already-loaded array +chat.messages.min_by(&:created_at) +chat.messages.find { |m| m.role == "user" } +chat.messages.sum(&:input_tokens) +``` + +## Single-Pass Iteration + +Don't iterate the same collection multiple times: + +```ruby +# ❌ BAD: Two passes over the same array +@monthly = @prices.select { |p| p.interval == "month" } +@yearly = @prices.select { |p| p.interval == "year" } + +# ✅ GOOD: Single pass with partition +@monthly, @yearly = @prices.partition { |p| p.interval == "month" } + +# ✅ GOOD: Single pass with group_by (for 3+ groups) +grouped = @prices.group_by(&:interval) +``` + +## Bulk Operations + +### Bulk Record Creation + +```ruby +# ❌ BAD: N inserts with N transactions +items.each { |attrs| Item.create(attrs) } + +# ✅ GOOD: 1 insert (skips validations/callbacks) +Item.insert_all(items_attributes) +``` + +### Bulk Job Enqueuing + +```ruby +# ❌ BAD: N enqueue operations +items.each { |item| ProcessItemJob.perform_later(item.id) } + +# ✅ GOOD: 1 bulk enqueue (~3.5x faster) +jobs = items.map { |item| ProcessItemJob.new(item.id) } +ActiveJob.perform_all_later(jobs) +``` + +## Lazy Loading Below the Fold + +Use Turbo Frames for content unlikely to be viewed immediately: + +```erb +<%# ❌ BAD: Renders immediately even if user never scrolls down %> +<%= render "comments", comments: @comments %> + +<%# ✅ GOOD: Loads only when visible (zero cost if never seen) %> +<%= turbo_frame_tag "comments", src: article_comments_path(@article), loading: :lazy do %> +

Loading comments...

+<% end %> +``` + +## Associations Over Methods + +Prefer associations over methods that return query results: + +```ruby +# ❌ BAD: Triggers SQL on every call, no caching +def active_subscription + subscriptions.where(active: true).first +end + +# ✅ GOOD: Cached after first load, works with includes +has_one :active_subscription, -> { where(active: true) }, class_name: "Subscription" +``` + +## Small Partials + +For very small, frequently-rendered partials (just a tag or two), consider using helpers instead: + +```ruby +# ❌ BAD: Partial with expensive lookup for simple markup +# _avatar.html.erb: <%= name %>'s avatar + +# ✅ GOOD: Helper (~2x faster than partial) +def avatar_tag(url, name) + tag.img(src: url, alt: "#{name}'s avatar") +end +``` + +Only optimize this way for truly small, high-frequency partials. Normal-sized partials are fine. diff --git a/.claude/rules/ruby.md b/.claude/rules/ruby.md new file mode 100644 index 0000000..5eebd54 --- /dev/null +++ b/.claude/rules/ruby.md @@ -0,0 +1,59 @@ +--- +description: Ruby/Rails code conventions +globs: ["**/*.rb"] +--- + +# Ruby Code Standards + +## Style + +- Use Ruby 3.x syntax features (pattern matching, endless methods where appropriate) +- Prefer `&&` and `||` over `and` and `or` +- Use trailing commas in multi-line arrays/hashes +- Maximum line length: 120 characters +- Use double quotes for strings unless interpolation is needed + +## Rails Conventions + +- **Fat models, thin controllers** - Business logic belongs in models +- **Use concerns** for shared behavior (named as adjectives: Closeable, Watchable) +- **Prefer scopes** over class methods for queries +- **Use `Current` attributes** for request-local state (Current.user, Current.session) +- **Database constraints over validations** where possible + +## Naming + +```ruby +# Methods that return boolean +def closed? = closure.present? +def can_edit? = author == Current.user + +# Action methods (verbs) +def close = create_closure! +def publish = update!(published_at: Time.current) + +# Scopes (adverbs/descriptors) +scope :chronologically, -> { order(created_at: :asc) } +scope :preloaded, -> { includes(:author, :comments) } +``` + +## Avoid + +- Service objects (use model methods or concerns) +- Query objects (use scopes on models) +- Callbacks for business logic (explicit method calls preferred) +- `before_action` chains that are hard to follow +- N+1 queries (use `includes` in controllers) +- `.count` on associations when counter cache exists (use `_count` column) +- `.find_by`/`.order.first` on preloaded associations (use Ruby `.find { }`/`.min_by`) +- Multiple passes over the same collection (use `partition`/`group_by`) +- Looped `create`/`perform_later` (use `insert_all`/`perform_all_later`) +- Empty directories created "for later" +- **`OpenStruct`** - never use it (slow, no typo protection, memory bloat). Use `Struct`, `Data`, plain hashes, or dedicated classes instead + +## Testing + +- Use Minitest, not RSpec +- Use fixtures, not factories +- Test behavior, not implementation +- One assertion per test when reasonable \ No newline at end of file diff --git a/.claude/rules/stimulus.md b/.claude/rules/stimulus.md new file mode 100644 index 0000000..93331df --- /dev/null +++ b/.claude/rules/stimulus.md @@ -0,0 +1,132 @@ +--- +description: Stimulus controller conventions +globs: ["app/javascript/controllers/**/*.js"] +--- + +# Stimulus Controller Standards + +## Philosophy + +- **JavaScript sprinkles, not applications** - Enhance HTML, don't replace it +- **Progressive enhancement** - Page works without JS +- **Data attributes for configuration** - No inline JS +- **One controller per concern** - Small, focused controllers + +## Structure + +```javascript +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + // Declare targets first + static targets = ["input", "output", "button"] + + // Then values + static values = { + url: String, + refreshInterval: { type: Number, default: 5000 } + } + + // Lifecycle callbacks + connect() { + // Called when controller connects to DOM + } + + disconnect() { + // Cleanup timers, subscriptions + } + + // Action methods (verb names) + toggle() { + this.outputTarget.classList.toggle("hidden") + } + + submit(event) { + event.preventDefault() + // Handle form submission + } +} +``` + +## Naming Conventions + +| Type | Convention | Example | +|------|------------|---------| +| Controller file | `name_controller.js` | `dropdown_controller.js` | +| Controller identifier | kebab-case | `data-controller="dropdown"` | +| Actions | verbs | `toggle`, `submit`, `open`, `close` | +| Targets | nouns | `input`, `menu`, `button` | +| Values | descriptive | `url`, `refreshInterval` | + +## HTML Usage + +```erb +<%# Controller attachment %> +
+ <%# Actions %> + + + <%# Targets %> + +
+ +<%# Multiple controllers %> +
+ ... +
+ +<%# Values %> +
+
+``` + +## Best Practices + +1. **Keep controllers small** - Under 50 lines ideally +2. **No direct DOM queries** - Use targets +3. **No global state** - Use values for configuration +4. **Clean up in disconnect()** - Remove timers, event listeners +5. **Use Turbo first** - Only add Stimulus when Turbo isn't enough + +## Anti-patterns + +```javascript +// ❌ BAD: Direct DOM query +document.querySelector(".menu") + +// ✅ GOOD: Use target +this.menuTarget + +// ❌ BAD: Inline event handler + + + +``` + +## Tailwind CSS + +```erb +<%# Use utility classes %> +
+<%= image_tag @user.avatar, class: "w-12 h-12 rounded-full" %> +
+

<%= @user.name %>

+

<%= @user.email %>

+
+
+``` + +## Colors (STRICT) + +**RULE:** Use OKLCH for custom colors. Standard Tailwind utilities are allowed. + +```erb +<%# ❌ BAD: Inline hex colors %> +
+ +<%# ❌ BAD: RGB in styles %> +
+ +<%# ✅ GOOD: Theme colors (defined as OKLCH) %> +
+ +<%# ✅ GOOD: Arbitrary OKLCH %> +
+ +<%# ✅ GOOD: Standard Tailwind utilities %> +
+``` + +## Helpers + +```ruby +# app/helpers/cards_helper.rb +module CardsHelper + def card_status_badge(card) + status = card.closed? ? "closed" : "open" + color = card.closed? ? "bg-gray-100 text-gray-600" : "bg-green-100 text-green-600" + + tag.span(status, class: "px-2 py-1 text-xs font-medium rounded #{color}") + end +end +``` + +## Anti-patterns + +```erb +<%# ❌ BAD: Logic in templates %> +<% cards = Card.where(board: @board).order(:created_at).limit(10) %> + +<%# ✅ GOOD: Query in controller/model %> +<% @cards = @board.cards.recent %> + +<%# ❌ BAD: Inline styles %> +
+ +<%# ✅ GOOD: Utility classes %> +
+ +<%# ❌ BAD: String concatenation for classes %> +
+ +<%# ✅ GOOD: class_names helper %> +
+ +<%# ❌ BAD: Inline SVG code %> + + + + +<%# ✅ GOOD: inline_svg gem %> +<%= inline_svg "icons/users.svg", class: "w-6 h-6 text-blue-600" %> +``` + +## Icons with inline_svg + +**STRICT RULE:** Never write inline SVG code directly in ERB files. + +Always use the `inline_svg` gem: + +```erb +<%= inline_svg "icons/users.svg", class: "w-6 h-6 text-blue-600" %> +<%= inline_svg "icons/chat.svg", class: "w-5 h-5 text-gray-400" %> +``` + +**Icon organization:** +- Store icons in `app/assets/images/icons/` +- Use semantic names: `users.svg`, `chat.svg`, `settings.svg` +- Keep SVG files clean (viewBox, paths only) +- Icons inherit color via `currentColor` diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..d9ec398 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "code-simplifier@claude-plugins-official": true + } +} diff --git a/.claude/skills/og-image/SKILL.md b/.claude/skills/og-image/SKILL.md index d62b31c..32f1ddc 100644 --- a/.claude/skills/og-image/SKILL.md +++ b/.claude/skills/og-image/SKILL.md @@ -1,200 +1,154 @@ --- name: og-image -description: Generate social media preview images (Open Graph) and configure meta tags. Creates a screenshot-optimized page using the project's existing design system, captures it at 1200x630, and sets up all social sharing meta tags. +description: Generate social media preview images (Open Graph) for Rails apps. Creates an OG image using the project's design system at /og-image, screenshots it at 1200x630, and configures meta tags in the layout head. --- -This skill creates professional Open Graph images for social media sharing. It analyzes the existing codebase to match the project's design system, generates a dedicated OG image page, screenshots it, and configures all necessary meta tags. +This skill creates Open Graph images for social media sharing in Rails apps. It generates a dedicated page at `/og-image` matching the project's design system, then screenshots it for use in meta tags. -## Workflow - -### Phase 1: Codebase Analysis - -Explore the project to understand: - -1. **Framework Detection** - - Check `package.json` for Next.js, Vite, Astro, Remix, etc. - - Identify the routing pattern (file-based, config-based) - - Find where to create the `/og-image` route - -2. **Design System Discovery** - - Look for Tailwind config (`tailwind.config.js/ts`) for color palette - - Check for CSS variables in global styles (`:root` definitions) - - Find existing color tokens, font families, spacing scales - - Look for a theme or design tokens file - -3. **Branding Assets** - - Find logo files in `/public`, `/assets`, `/src/assets` - - Check for favicon, app icons - - Look for existing hero sections or landing pages with branding - -4. **Product Information** - - Extract product name from `package.json`, landing page, or meta tags - - Find tagline/description from existing pages - - Look for existing OG/meta configuration to understand current setup - -5. **Existing Components** - - Find reusable UI components that could be leveraged - - Check for glass effects, gradients, or distinctive visual patterns - - Identify the overall aesthetic (dark mode, light mode, etc.) - -### Phase 2: OG Image Page Creation - -Create a dedicated route at `/og-image` (or equivalent for the framework): - -**Page Requirements:** -- Fixed dimensions: exactly 1200px wide × 630px tall -- Self-contained styling (no external dependencies that might not render) -- Hide any dev tool indicators with CSS: -```css -[data-nextjs-dialog-overlay], -[data-nextjs-dialog], -nextjs-portal, -#__next-build-indicator { - display: none !important; -} -``` +## Architecture -**Content Structure:** -- Product logo/icon (prominent placement) -- Product name with distinctive typography -- Tagline or value proposition -- Visual representation of the product (mockup, illustration, or abstract design) -- URL/domain at the bottom -- Background that matches the project aesthetic (gradients, patterns, etc.) +The OG system has three layers: -**Design Principles:** -- Use the project's existing color palette -- Match the typography from the main site -- Include visual elements that represent the product -- Ensure high contrast for readability at small sizes (social previews are often small) -- Test that text is readable when the image is scaled down to ~400px wide +1. **Helpers** in `ApplicationHelper` — `og_title`, `og_description`, `og_image` +2. **Meta tags** in `app/views/layouts/application.html.erb` — call the helpers +3. **Per-page overrides** — any view sets `content_for` to customize -### Phase 3: Screenshot Capture +``` +Layout meta tags + └─ og_title → content_for(:og_title) → content_for(:title) → t("app_name") + └─ og_description → content_for(:og_description) → t("og_image.description") + └─ og_image → content_for(:og_image) → request.base_url + "/og-image.png" +``` -Use Playwright to capture the OG image: +## Existing Files -1. Navigate to the OG image page (typically `http://localhost:3000/og-image` or similar) -2. Resize viewport to exactly 1200×630 -3. Wait for any animations to complete or fonts to load -4. Take a PNG screenshot -5. Save to the project's public folder as `og-image.png` +These files already exist and should be edited, not recreated: -**Playwright Commands:** -``` -browser_navigate: http://localhost:{port}/og-image -browser_resize: width=1200, height=630 -browser_take_screenshot: og-image.png (then copy to /public) -``` +| File | Purpose | +|------|---------| +| `app/helpers/application_helper.rb` | `og_title`, `og_description`, `og_image` helpers | +| `app/views/layouts/application.html.erb` | OG + Twitter meta tags in `` | +| `app/controllers/og_images_controller.rb` | Renders the screenshot page | +| `app/views/og_images/show.html.erb` | 1200x630 self-contained HTML page | +| `config/routes.rb` | `GET /og-image` route | +| `config/locales/en.yml` | `og_image.tagline` and `og_image.description` | +| `lib/tasks/og_image.rake` | `rake og_image:generate` and `rake og_image:instructions` | -### Phase 4: Meta Tag Configuration - -Audit and update the project's meta tag configuration. For Next.js App Router, update `layout.tsx`. For other frameworks, update the appropriate location. - -**Required Meta Tags:** - -```typescript -// Open Graph -openGraph: { - title: "Product Name - Short Description", - description: "Compelling description for social sharing", - url: "https://yourdomain.com", - siteName: "Product Name", - locale: "en_US", - type: "website", - images: [{ - url: "/og-image.png", // or absolute URL - width: 1200, - height: 630, - alt: "Descriptive alt text for accessibility", - type: "image/png", - }], -}, - -// Twitter/X -twitter: { - card: "summary_large_image", - title: "Product Name - Short Description", - description: "Compelling description for Twitter", - creator: "@handle", // if provided - images: [{ - url: "/og-image.png", - width: 1200, - height: 630, - alt: "Descriptive alt text", - }], -}, - -// Additional -other: { - "theme-color": "#000000", // match brand color - "msapplication-TileColor": "#000000", -}, - -appleWebApp: { - title: "Product Name", - statusBarStyle: "black-translucent", - capable: true, -}, -``` +## Workflow -**Ensure `metadataBase` is set** for relative URLs to resolve correctly: -```typescript -metadataBase: new URL("https://yourdomain.com"), +### Phase 1: Understand the Design System + +Read these files to match the project aesthetic: + +- `app/assets/tailwind/application.css` — OKLCH color palette, fonts, custom utilities +- `app/views/layouts/application.html.erb` — existing meta tags and structure +- `app/assets/images/icons/` — available SVG icons for the image +- `config/locales/en.yml` — app name, tagline, description + +### Phase 2: Update the OG Image Page + +Edit `app/views/og_images/show.html.erb`. This is a self-contained page with `layout false`. + +**Requirements:** +- Exactly 1200px wide × 630px tall +- Uses the project's Tailwind stylesheet +- All custom colors must use OKLCH (project rule) +- Uses `inline_svg` for icons (project rule — never inline SVG) +- No authentication required + +**Template structure:** + +```erb + + + + + OG Image + <%%= stylesheet_link_tag "tailwind" %> + + + +
+ <%%= inline_svg "icons/lightning.svg", class: "w-14 h-14 text-white" %> +

<%%= @app_name %>

+

<%%= @tagline %>

+ <%%= @domain %> +
+ + ``` -### Phase 5: Verification & Output +**Controller provides:** +- `@app_name` — from `t("app_name")` +- `@tagline` — from `t("og_image.tagline")` +- `@domain` — from `request.host` -1. **Verify the image exists** at the public path -2. **Check meta tags** are correctly rendered in the HTML -3. **Provide cache-busting instructions:** - - Facebook/LinkedIn: https://developers.facebook.com/tools/debug/ - - Twitter/X: https://cards-dev.twitter.com/validator - - LinkedIn: https://www.linkedin.com/post-inspector/ +### Phase 3: Screenshot -4. **Summary output:** - - Path to generated OG image - - URL to preview the OG image page locally - - List of meta tags added/updated - - Links to social preview debuggers +Tell the user to generate the static image: -## Prompting for Missing Information - -Only ask the user if these cannot be determined from the codebase: +``` +1. Start server: bin/dev +2. Open: http://localhost:3000/og-image +3. DevTools (F12) → device toolbar → 1200 x 630 +4. Right-click → "Capture screenshot" +5. Save as: public/og-image.png +``` -1. **Domain/URL** - If not found in existing config, ask: "What's your production domain? (e.g., https://example.com)" +Or with Playwright: `rake og_image:generate` -2. **Twitter/X handle** - If adding twitter:creator, ask: "What's your Twitter/X handle for attribution? (optional)" +### Phase 4: Per-Page OG Images -3. **Tagline** - If no clear tagline found, ask: "What's a short tagline for social previews? (1 sentence)" +Any view can override defaults using `content_for`: -## Framework-Specific Notes +```erb +<%% content_for :og_title, @post.title %> +<%% content_for :og_description, @post.excerpt %> +<%% content_for :og_image, "/og-images/posts/#{@post.id}.png" %> +``` -**Next.js App Router:** -- Create `/app/og-image/page.tsx` -- Update metadata in `/app/layout.tsx` -- Use `'use client'` directive for the OG page +The helpers in `ApplicationHelper` cascade: + +```ruby +def og_title + content_for(:og_title).presence || content_for(:title).presence || t("app_name") +end + +def og_description + content_for(:og_description).presence || t("og_image.description") +end + +def og_image + if content_for?(:og_image) + src = content_for(:og_image) + src.start_with?("http") ? src : "#{request.base_url}#{src}" + else + "#{request.base_url}/og-image.png" + end +end +``` -**Next.js Pages Router:** -- Create `/pages/og-image.tsx` -- Update `_app.tsx` or use `next-seo` +### Phase 5: Verify -**Vite/React:** -- Create route via router config -- Update `index.html` meta tags or use `react-helmet` +- [ ] `/og-image` renders at 1200×630 +- [ ] `public/og-image.png` exists +- [ ] Meta tags render in page source (`og:image`, `twitter:image`) +- [ ] `og:image` URL is absolute (includes protocol + domain) +- [ ] Per-page overrides work via `content_for` +- [ ] `rails test` passes +- [ ] `bundle exec rubocop -A` passes -**Astro:** -- Create `/src/pages/og-image.astro` -- Update layout with meta tags +Social preview debuggers: +- Facebook: https://developers.facebook.com/tools/debug/ +- Twitter: https://cards-dev.twitter.com/validator +- LinkedIn: https://www.linkedin.com/post-inspector/ -## Quality Checklist +## Key Rules -Before completing, verify: -- [ ] OG image renders correctly at 1200×630 -- [ ] No dev tool indicators visible in screenshot -- [ ] Image saved to public folder -- [ ] Meta tags include og:image with absolute URL capability -- [ ] Meta tags include twitter:card as summary_large_image -- [ ] Meta tags include dimensions (width/height) -- [ ] Meta tags include alt text for accessibility -- [ ] theme-color is set to match brand -- [ ] User informed of cache-busting URLs +- **No env vars** — domain comes from `request.base_url` (configured via `bin/configure`) +- **No hardcoded strings** — app name and tagline come from i18n (`config/locales/en.yml`) +- **OKLCH colors only** — no hex/rgb/hsl in custom CSS +- **`inline_svg` for icons** — never paste raw SVG into templates +- **Minitest + fixtures** — for any new tests diff --git a/.cursor/rules/clean-code.mdc b/.cursor/rules/clean-code.mdc deleted file mode 100644 index 692cdbd..0000000 --- a/.cursor/rules/clean-code.mdc +++ /dev/null @@ -1,46 +0,0 @@ - -## Constants Over Magic Numbers -- Replace hard-coded values with named constants. -- Use descriptive constant names that explain the value's purpose. -- Keep constants at the top of the file or in a dedicated constants file. - -## Meaningful Names -- Variables, functions, and classes should reveal their purpose. -- Names should explain why something exists and how it's used. -- Avoid abbreviations unless they're universally understood. - -## Smart Comments -- Don't comment on what the code does — make the code self-documenting. -- Use comments to explain *why* something is done a certain way, not *what* is done. -- Document APIs, complex algorithms, and non-obvious side effects. - -## Single Responsibility -- Each function should do exactly one thing. -- Functions should be small and focused. -- If a function needs a comment to explain what it does, it should be split. - -## DRY (Don't Repeat Yourself) -- Extract repeated code into reusable functions. -- Share common logic through proper abstraction. -- Maintain single sources of truth. - -## Clean Structure -- Keep related code together. -- Organize code in a logical hierarchy. -- Use consistent file and folder naming conventions. - -## Encapsulation -- Hide implementation details. -- Expose clear interfaces. -- Move nested conditionals into well-named functions. - -## Code Quality Maintenance -- Refactor continuously. -- Fix technical debt early, before it accumulates. -- **Follow the Boy Scout Principle: always leave the code cleaner than you found it.** -- Treat small improvements as part of daily work, not as separate refactoring phases. - -## Testing -- Write tests before fixing bugs. -- Keep tests readable and maintainable. -- Test edge cases and error conditions. diff --git a/.cursor/rules/code-quality.mdc b/.cursor/rules/code-quality.mdc deleted file mode 100644 index 1a484e9..0000000 --- a/.cursor/rules/code-quality.mdc +++ /dev/null @@ -1,44 +0,0 @@ - -## Verify Information -Always verify information before presenting it. Do not make assumptions or speculate without clear evidence. - -## File-by-File Changes -Make changes file by file and give me a chance to spot mistakes. - -## Reasonably Sized Changes -If a request the user makes can end up affecting many (more than 5) files, always propose changes gradually. -That means you will implement the changes on a select number of files, then ask the user to confirm this is his intent, and only then proceed with the rest of the changes. - -## No Apologies -Never use apologies. - -## No Understanding Feedback -Avoid giving feedback about understanding in comments or documentation. - -## No Whitespace Suggestions -Don't suggest whitespace changes. - -## Always Summarize -Always summarize changes made. - -## No Inventions -- Don't invent changes other than what's explicitly requested, except for where the "boy scout" principle applies, which is improving *related* code. -- Do not implement documentation files, example files, test files or other additions, without explicitly asking if the user wants them. - -## No Unnecessary Confirmations -Don't ask for confirmation of information already provided in the context. - -## Preserve Existing Code -Don't remove unrelated code or functionalities. Pay attention to preserving existing structures. - -## No Implementation Checks -Don't ask the user to verify implementations that are visible in the provided context. - -## No Unnecessary Updates -Don't suggest updates or changes to files when there are no actual modifications needed. - -## Provide Real File Links -Always provide links to the real files, not x.md. - -## No Current Implementation -Don't show or discuss the current implementation unless specifically requested. diff --git a/.cursor/rules/rails.mdc b/.cursor/rules/rails.mdc deleted file mode 100644 index bc1df44..0000000 --- a/.cursor/rules/rails.mdc +++ /dev/null @@ -1,184 +0,0 @@ ---- -description: Rails 8 specific rules and guidelines for the Social Script project. These rules complement the main .cursorrules file with detailed Rails-specific practices. -globs: ["*.rb", "*.erb", "*.rake", "Gemfile", "Rakefile", "config/**/*.yml", "config/**/*.rb", "db/migrate/*.rb", "app/**/*"] ---- - -# Your rule content - -- You can @ files here -- You can use markdown but dont have to - -# Rails 8 Development Guidelines - -## 1. Rails 8 Core Features - -** Prefer the command line utilities to manually generated code ** - -e.g use `rails generate model` instead of creating a model from scratch - -** IMPORTANT: Server Management ** -- Always use `bin/dev` to start the server (uses Procfile.dev) -- Check logs after every significant change -- Monitor development.log for errors and performance issues -- Use `tail -f log/development.log` for real-time monitoring -- Review logs before considering any change complete -- Use modern Ruby 3.4 and Rails 8.1 syntax -- Avoid using deprecated patterns, like OpenStruct - -1. **Modern Infrastructure** - - Use Thruster for asset compression and caching - - Implement Kamal 2 for deployment orchestration - - Utilize Solid Queue for background job processing - - Leverage Solid Cache for caching - - Use Solid Cable for real-time features - - Configure healthcheck silencing in production logs - -2. **Database Best Practices** - - Use ULID as as the primary key: - ``` - create_table :table, force: true, id: false do |t| - t.primary_key :id, :string, default: -> { "ULID()" } - ... - end - ``` - - Use SQLite full-text search capabilities - - Configure proper database extensions in database.yml - - Implement database partitioning for large datasets - - Use proper database indexing strategies - - Configure connection pooling - - Implement proper backup strategies - - Use SQLite-specific features - - Monitor and optimize query performance - -3. **Controller Patterns** - - Use `params.expect()` for safer parameter handling - - Implement rate limiting via cache store - - Use the new sessions generator for authentication - - Silence healthcheck requests in production - - Keep controllers RESTful and focused - - Use service objects for complex business logic - -4. **Progressive Web App Features** - - Utilize default PWA manifest - - Implement service worker functionality - - Configure browser version requirements - - Use `allow_browser` to set minimum versions - - Implement offline capabilities - - Configure proper caching strategies - -## 2. Development Standards - -1. **Code Organization** - - Follow Single Responsibility Principle - - Use service objects for complex business logic - - Keep controllers skinny - - Use concerns for shared functionality - - Use `params.expect()` instead of strong parameters - - Follow Rails 8 conventions - -2. **Performance** - - Use Thruster for asset compression - - Implement proper caching with Solid Cache - - Configure connection pooling - - Use Solid Queue for background jobs - - Monitor application metrics - - Regular performance profiling - - Optimize database queries - - Use proper indexing strategies - -3. **Testing** - - Write comprehensive Minitest tests - - Use factories instead of fixtures - - Test happy and edge cases - - Keep tests DRY but readable - - Use parallel testing by default - - Regular security testing - - Performance testing - - Load testing for critical paths - -4. **Security** - - Use `params.expect()` for parameter handling - - Implement proper authorization - - Sanitize user input - - Follow OWASP guidelines - - Configure rate limiting via cache store - - Regular security audits - - Keep dependencies updated - - Use secure communication (HTTPS) - -5. **Hotwire Patterns** - - Use Turbo Frames for partial page updates - - Use Turbo Streams for real-time updates - - Keep Stimulus controllers focused and simple - - Use data attributes for JavaScript hooks - - Use Solid Cable for real-time features - -6. **Deployment** - - Use Kamal 2 for deployment orchestration - - Configure healthcheck silencing - - Use Propshaft for asset pipeline - - Implement PWA features by default - - Use devcontainer for development - - Implement blue-green deployments - - Configure proper health checks - - Set up monitoring and alerts - -7. **Logging and Monitoring** - - Check logs after every code change - - Monitor development.log for errors - - Use `tail -f log/development.log` for real-time monitoring - - Review logs before marking tasks as complete - - Set up proper log rotation - - Configure log levels appropriately - - Monitor performance metrics - - Track error rates and patterns - -## 3. Directory Structure - -``` -/app -├── components/ # View components -│ └── ui/ # UI components -├── controllers/ # Controllers -├── models/ # Active Record models -├── views/ # View templates -├── helpers/ # View helpers -├── javascript/ # Stimulus controllers -│ └── controllers/ -├── services/ # Service objects -├── policies/ # Pundit policies -├── jobs/ # Background jobs -├── mailers/ # Action Mailer classes -└── assets/ # Assets (if not using importmap) -``` - -## 4. Tech Stack - -- **Backend**: Ruby on Rails 8 -- **Frontend**: Hotwire (Turbo + Stimulus) -- **Styling**: Tailwind CSS -- **Database**: SQLite -- **Testing**: Minitest -- **Background Jobs**: Solid Queue (default in Rails 8) -- **Caching**: Solid Cache (default in Rails 8) -- **Real-time**: Solid Cable -- **Authentication**: Built-in Sessions Generator -- **Authorization**: Pundit -- **Deployment**: Kamal 2 (default in Rails 8) -- **Asset Pipeline**: Propshaft (default in Rails 8) -- **Container**: Docker (development & production) - -## 5. Rails-Specific Reminders - -1. Use `--skip-solid` if not using Solid Stack -2. Configure healthcheck silencing in production -3. Ensure Docker services are running before development -4. Follow the new Rails 8 maintenance policy -5. Keep dependencies updated -6. Monitor application performance -7. Regular security audits -8. Use `params.expect()` instead of strong parameters -9. Use Propshaft for asset pipeline -10. Implement PWA features by default -11. Always use `bin/dev` to start the server -12. Check logs after every significant change diff --git a/.gitignore b/.gitignore index 13865f8..04962da 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,6 @@ # Ignore bundler config. /.bundle -# Ignore all environment files. -/.env* - # Ignore all logfiles and tempfiles. /log/* /tmp/* @@ -33,19 +30,19 @@ # Ignore master key for decrypting credentials and more. /config/master.key /config/credentials/*.key +/config/*.key + +# Ignore configuration marker +/.configured /app/assets/builds/* !/app/assets/builds/.keep -# Ignoe macOS files +# Ignore macOS files .DS_Store # Ignore Rubymine files .idea/ -/config/credentials/development.key - -/config/credentials/production.key - -# MaxMind GeoLite2 database (download separately - not redistributable) -/db/*.mmdb +# MaxMind GeoLite2 database (not redistributable) +db/*.mmdb diff --git a/.kamal/secrets b/.kamal/secrets index 6e61960..7d79fda 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -2,21 +2,13 @@ # and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. -# Example of extracting secrets from 1password (or another compatible pw manager) -# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) -# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) -# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) - -# Use a GITHUB_TOKEN if private repositories are needed for the image -# GITHUB_TOKEN=$(gh config get -h github.com oauth_token) +# Rails master key for production credentials +# This is the only secret needed - everything else comes from encrypted credentials +RAILS_MASTER_KEY=$(cat config/credentials/production.key) # Grab the registry password from Rails credentials KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password) -# Improve security by using a password manager. Never check config/master.key into git! -RAILS_MASTER_KEY=$(cat config/credentials/production.key) - -# MaxMind credentials for GeoLite2 database (get from https://www.maxmind.com/en/accounts/current/license-key) -# As of May 2024, MaxMind requires both account_id and license_key for downloads -MAXMIND_ACCOUNT_ID=$(rails credentials:fetch --environment production maxmind.account_id 2>/dev/null || echo "") -MAXMIND_LICENSE_KEY=$(rails credentials:fetch --environment production maxmind.license_key 2>/dev/null || echo "") +# MaxMind credentials for GeoLite2 database download during Docker build (optional) +MAXMIND_ACCOUNT_ID=$(RAILS_ENV=production bin/rails credentials:show 2>/dev/null | ruby -ryaml -e "puts YAML.safe_load(STDIN.read).dig('maxmind', 'account_id')" 2>/dev/null || echo "") +MAXMIND_LICENSE_KEY=$(RAILS_ENV=production bin/rails credentials:show 2>/dev/null | ruby -ryaml -e "puts YAML.safe_load(STDIN.read).dig('maxmind', 'license_key')" 2>/dev/null || echo "") diff --git a/AGENTS.md b/AGENTS.md index 01833ff..4a2b5e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,8 @@ rails generate migration MigrationName ## Architecture & Key Patterns +The codebase follows **37signals vanilla Rails style**: fat models with concerns, thin controllers, no service objects. All domain logic lives in models and model concerns. There is no `app/services/` directory. + ### Data Model Structure The application uses a **Universal Content Model** where the `Post` model handles three distinct content types via the `post_type` enum: @@ -84,21 +86,77 @@ The application uses a **Universal Content Model** where the `Post` model handle - `Comment`: User feedback on posts with published flag - `Report`: Content moderation by trusted users, auto-hides after 3+ reports - `Testimonial`: User testimonials ("why I love Ruby") with AI-generated headline, subheadline, and quote. Validated by LLM for appropriateness -- `Project`: GitHub repositories for users with star counts, language, description. Replaces the old `github_repos` JSON column +- `Project`: GitHub repositories for users with star counts, language, description - `StarSnapshot`: Daily star count snapshots per project, used to compute trending/stars gained +- `Chat`: RubyLLM chat records (`acts_as_chat`). Has a `purpose` column: `"conversation"` (default), `"summary"`, `"testimonial_generation"`, `"testimonial_validation"`. System chats (non-conversation) track AI operations for cost accounting +- `Model`: RubyLLM model records (`acts_as_model`). Tracks available AI models and their cumulative costs + +### Concern Catalog + +All domain logic that was previously in service objects now lives in model concerns, following the 37signals naming convention (adjective-named, namespaced to the model they belong to). + +**User concerns** (`app/models/concerns/user/`): +- `User::Geocodable` — geocodes free-text locations into structured data (city, country, coordinates) via Photon API, resolves timezone from coordinates +- `User::GithubSyncable` — syncs GitHub profile data and repositories via GraphQL API on sign-in, supports batch fetching for bulk updates + +**Post concerns** (`app/models/concerns/post/`): +- `Post::SvgSanitizable` — sanitizes SVG content to prevent XSS attacks in success story logos +- `Post::MetadataFetchable` — fetches OpenGraph metadata and external content from URLs for link posts +- `Post::ImageVariantable` — processes featured images into WebP variants (small, medium, large, og) via ActiveStorage +- `Post::OgImageGeneratable` — generates OG images for success stories from SVG logos +- `Post::AiSummarizable` — generates AI summary teasers via RubyLLM, creates a system chat with `purpose: "summary"` + +**Testimonial concerns** (`app/models/concerns/testimonial/`): +- `Testimonial::AiGeneratable` — AI-generates headline, subheadline, and body text from user testimonial quote via RubyLLM +- `Testimonial::AiValidatable` — validates testimonial content for appropriateness via RubyLLM + +**Shared concerns** (`app/models/concerns/`): +- `Costable` — cost formatting and calculation for models with a `total_cost` column (used by User, Chat, Model) + +### AI Operations (RubyLLM) + +All AI operations use the **RubyLLM** gem (~> 1.9), configured with `default_model: "gpt-4.1-nano"` in `config/initializers/ruby_llm.rb`. + +**How AI operations work:** +1. A concern method (e.g., `Post#generate_summary!`) creates a system `Chat` record with a specific `purpose` +2. It calls `chat.ask(prompt)` which uses RubyLLM to send the request and record the response as messages +3. Message costs are tracked automatically via RubyLLM's `acts_as_chat` / `acts_as_model` +4. Per-user spending is available via the `Costable` concern on `User` + +**Chat purposes:** +- `"conversation"` — default, for user-facing chats (not currently used in this app) +- `"summary"` — AI summary generation for posts +- `"testimonial_generation"` — AI headline/subheadline/body generation for testimonials +- `"testimonial_validation"` — AI content validation for testimonials + +**Key pattern:** Jobs are thin delegators that call model methods. The model concern owns all the logic: +```ruby +# Job (thin delegator) +class GenerateSummaryJob < ApplicationJob + def perform(post, force: false) + post.generate_summary!(force: force) + end +end + +# Concern (owns the logic) +module Post::AiSummarizable + def generate_summary!(force: false) + chat = user.chats.create!(purpose: "summary", model: Model.find_by(...)) + response = chat.ask(prompt) + update!(summary: clean_ai_summary(response.content)) + end +end +``` ### Primary Keys & IDs -**All tables use UUIDv7 string primary keys** (migrated from ULID): +**All tables use UUIDv7 string primary keys** via the `uuid7()` SQLite function: ```ruby -create_table :table_name, id: false do |t| - t.primary_key :id, :string, default: -> { "uuid_generate_v7()" } +create_table :table_name, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| # ... end ``` -UUIDv7 provides time-ordered, universally unique IDs without requiring the `sqlite-ulid` extension. - ### Authentication & Authorization - **Authentication**: GitHub OAuth only via Devise + OmniAuth @@ -117,18 +175,20 @@ UUIDv7 provides time-ordered, universally unique IDs without requiring the `sqli ### Background Jobs (SolidQueue) -- `GenerateSummaryJob`: Creates AI summaries for new/updated posts using OpenAI or Anthropic APIs -- `GenerateSuccessStoryImageJob`: Generates OG images for success stories from SVG logos -- `GenerateTestimonialFieldsJob`: AI-generates headline, subheadline, and quote from user testimonial text -- `ValidateTestimonialJob`: LLM-validates testimonial content for appropriateness +All jobs are thin delegators that call model methods. The logic lives in model concerns, not in jobs. + +- `GenerateSummaryJob`: Calls `post.generate_summary!` (AI summary via `Post::AiSummarizable`) +- `GenerateSuccessStoryImageJob`: Calls `post.generate_og_image!` (OG image via `Post::OgImageGeneratable`) +- `GenerateTestimonialJob`: Calls `testimonial.generate_ai_fields!` (AI fields via `Testimonial::AiGeneratable`) +- `ValidateTestimonialJob`: Calls `testimonial.validate_with_ai!` (AI validation via `Testimonial::AiValidatable`) - `NotifyAdminJob`: Sends notifications when content is auto-hidden -- `UpdateGithubDataJob`: Refreshes user GitHub data via GraphQL API (repositories, stars, etc.) -- `NormalizeLocationJob`: Geocodes user locations via OpenAI for map display +- `UpdateGithubDataJob`: Batch-syncs GitHub data via `User.batch_sync_github_data!` (from `User::GithubSyncable`) +- `NormalizeLocationJob`: Calls `user.geocode!` (geocoding via `User::Geocodable`) - `ScheduledNewsletterJob`: Sends newsletter emails at timezone-appropriate times ### Image Processing -Posts support `featured_image` attachments via ActiveStorage. Images are processed into multiple variants (small, medium, large, og) and stored as separate blobs with metadata in `image_variants` JSON column. Processing uses the `ImageProcessor` service. +Posts support `featured_image` attachments via ActiveStorage. Images are processed into multiple WebP variants (small, medium, large, og) and stored as separate blobs with metadata in `image_variants` JSON column. Processing is handled by the `Post::ImageVariantable` concern. ### URL Routing Pattern @@ -155,17 +215,6 @@ Domain config lives in `config/initializers/domains.rb`. In development, communi **Footer legal links**: Use `main_site_url(path)` helper to ensure legal page links resolve to the primary domain when viewed on the community domain. -### Services Layer - -Service objects in `app/services/` handle complex operations: -- `GithubDataFetcher`: Fetches and updates user GitHub profile data and repositories via GraphQL API on sign-in -- `ImageProcessor`: Processes featured images into multiple variants (small, medium, large, og) -- `SuccessStoryImageGenerator`: Generates OG images for success stories from SVG logos -- `SvgSanitizer`: Sanitizes SVG content to prevent XSS attacks -- `LocationNormalizer`: Geocodes free-text user locations into structured data (city, country, coordinates) using OpenAI -- `TimezoneResolver`: Resolves timezone from coordinates, normalizes legacy timezone identifiers -- `MetadataFetcher`: Fetches OpenGraph metadata from URLs for link posts - ### FriendlyId Implementation Both `User` and `Post` models use FriendlyId with history: @@ -192,11 +241,10 @@ Both models implement `create_slug_history` to manually save old slugs when chan ### Migrations -Always use UUIDv7 string primary keys. Never use auto-increment integers: +Always use UUIDv7 string primary keys via the `uuid7()` SQLite function. Never use auto-increment integers: ```ruby -create_table :posts, id: false do |t| - t.primary_key :id, :string, default: -> { "uuid_generate_v7()" } +create_table :posts, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| t.string :title, null: false # ... end @@ -240,7 +288,7 @@ github: client_secret: your_github_oauth_app_client_secret openai: - api_key: your_openai_api_key # Optional - for AI summaries + api_key: your_openai_api_key # Used by RubyLLM for AI operations ``` ### GitHub OAuth Setup diff --git a/Dockerfile b/Dockerfile index 6729d77..7ad187d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,8 @@ # check=error=true # This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: -# docker build -t template . -# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name template template +# docker build -t why_ruby . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name why_ruby why_ruby # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html @@ -16,29 +16,30 @@ WORKDIR /rails RUN echo 'Acquire::http::Pipeline-Depth 0;\nAcquire::http::No-Cache true;\nAcquire::BrokenProxy true;\n' > /etc/apt/apt.conf.d/99fixbadproxy - -# Install base packages +# Install base packages and set up jemalloc RUN rm -rf /var/lib/apt/lists/* && \ apt-get update -qq --fix-missing && \ - apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 imagemagick librsvg2-bin && \ - rm -rf /var/lib/apt/lists/* + apt-get install --no-install-recommends -y curl libjemalloc2 libssl-dev libvips sqlite3 imagemagick librsvg2-bin && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/lib/libjemalloc.so.2 # Set production environment ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" + BUNDLE_WITHOUT="development:test" \ + LD_PRELOAD="/usr/lib/libjemalloc.so.2" # Throw-away build stage to reduce size of final image FROM base AS build # Install packages needed to build gems RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git pkg-config libyaml-dev zlib1g-dev && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config zlib1g-dev && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems -COPY Gemfile Gemfile.lock ./ +COPY Gemfile Gemfile.lock vendor ./ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ bundle exec bootsnap precompile --gemfile @@ -46,23 +47,24 @@ RUN bundle install && \ # Copy application code COPY . . -# Download MaxMind GeoLite2 database for IP geolocation (requires account ID and license key) -# MaxMind API changed in May 2024 to require Basic Auth with account_id:license_key +# Download MaxMind GeoLite2 database for IP geolocation (optional) RUN --mount=type=secret,id=MAXMIND_ACCOUNT_ID \ --mount=type=secret,id=MAXMIND_LICENSE_KEY \ if [ -f /run/secrets/MAXMIND_ACCOUNT_ID ] && [ -f /run/secrets/MAXMIND_LICENSE_KEY ]; then \ ACCOUNT_ID="$(cat /run/secrets/MAXMIND_ACCOUNT_ID)" && \ LICENSE_KEY="$(cat /run/secrets/MAXMIND_LICENSE_KEY)" && \ - curl -sL -u "${ACCOUNT_ID}:${LICENSE_KEY}" \ - "https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz" | \ - tar -xzf - --strip-components=1 -C db/ --wildcards "*/*.mmdb" && \ - echo "GeoLite2 database downloaded successfully"; \ + curl -sfL -o /tmp/geolite2.tar.gz -u "${ACCOUNT_ID}:${LICENSE_KEY}" \ + "https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz" && \ + tar -xzf /tmp/geolite2.tar.gz --strip-components=1 -C db/ --wildcards "*/*.mmdb" && \ + rm -f /tmp/geolite2.tar.gz && \ + echo "GeoLite2 database downloaded successfully" || \ + echo "WARNING: GeoLite2 download failed, skipping (non-fatal)"; \ else \ echo "MAXMIND credentials not provided, skipping GeoLite2 download"; \ fi -# Precompile bootsnap code for faster boot times -RUN bundle exec bootsnap precompile app/ lib/ +# Precompile bootsnap code for faster boot times (use -j 1 for QEMU compatibility) +RUN bundle exec bootsnap precompile -j 1 app/ lib/ # Precompiling assets for production with RAILS_MASTER_KEY from secrets RUN --mount=type=secret,id=RAILS_MASTER_KEY \ @@ -70,19 +72,20 @@ RUN --mount=type=secret,id=RAILS_MASTER_KEY \ ./bin/rails assets:precompile - - # Final stage for app image FROM base -# Copy built artifacts: gems, application -COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" -COPY --from=build /rails /rails +# OCI labels +LABEL org.opencontainers.image.source="https://github.com/newstler/why_ruby" # Run and own only the runtime files as a non-root user for security RUN groupadd --system --gid 1000 rails && \ - useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ - chown -R rails:rails db log storage tmp + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash + +# Copy built artifacts: gems, application +COPY --from=build --chown=rails:rails "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build --chown=rails:rails /rails /rails + USER 1000:1000 # Entrypoint prepares the database. diff --git a/Gemfile b/Gemfile index be06842..fa5238a 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,7 @@ gem "rails", github: "rails/rails", branch: "main" gem "propshaft" # Use sqlite3 as the database for Active Record gem "sqlite3", ">= 2.1" -gem "sqlean", "~> 0.2" +gem "sqlean", "~> 0.2" # SQLite extensions including uuid7() # Use the Puma web server [https://github.com/puma/puma] gem "puma", ">= 5.0" # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] @@ -37,31 +37,36 @@ gem "kamal", require: false # Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] gem "thruster", require: false -# Authentication -gem "devise", "~> 4.9" +# Litestream for SQLite replication [https://github.com/fractaledmind/litestream-ruby] +gem "litestream" + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +gem "image_processing", "~> 1.2" + +# Authentication (GitHub OAuth only, no Devise) gem "omniauth-github", "~> 2.0" -gem "omniauth-rails_csrf_protection", "~> 1.0" +gem "omniauth-rails_csrf_protection", "~> 2.0" # Admin -gem "avo", ">= 3.2" +gem "madmin", "~> 2.1" # Markdown and code syntax highlighting -gem "redcarpet", "~> 3.6" -gem "rouge", "~> 4.0" +gem "redcarpet" +gem "rouge" -# AI integration for summaries -gem "ruby-openai", "~> 8.2" -gem "anthropic", "~> 1.6.0" +# AI integration +gem "ruby_llm", "~> 1.9" # Pagination gem "kaminari", "~> 1.2" # IP Geolocation (for analytics country code) -gem "geocoder", "~> 1.8" -gem "maxminddb", "~> 0.1" +gem "geocoder" +gem "maxminddb" # Friendly URLs -gem "friendly_id", "~> 5.5" +gem "friendly_id" +gem "babosa" # Timezone lookup from coordinates (offline, pure Ruby) gem "wheretz" @@ -69,6 +74,21 @@ gem "wheretz" # HTML/XML parsing gem "nokogiri", "~> 1.16" +# Icons +gem "inline_svg" + +# MCP: Model Context Protocol +gem "fast-mcp", "~> 1.6" + +# Billing +gem "stripe" + +# Multilingual content +gem "mobility", "~> 1.3" + +# Monitor performance +gem "rorvswild", "~> 1.9" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" @@ -83,6 +103,9 @@ group :development, :test do # Git hooks manager - automatically runs RuboCop before commits gem "lefthook", require: false + + # i18n tasks for managing translations [https://github.com/glebm/i18n-tasks] + gem "i18n-tasks", "~> 1.0" end group :development do @@ -97,10 +120,4 @@ group :test do gem "webmock" end -# Backup data to S3 -gem "litestream", "~> 0.14.0" - -# Monitor performance -gem "rorvswild", "~> 1.9" - gem "tidewave", "~> 0.4.1", group: :development diff --git a/Gemfile.lock b/Gemfile.lock index 0dea60b..298bbd1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rails/rails.git - revision: 65e0e67906433b550377fd201a4d512f643e2b28 + revision: a0da6b991a1c2cb1df2560a74ae3a86bc988f231 branch: main specs: actioncable (8.2.0.alpha) @@ -31,7 +31,7 @@ GIT rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) + rails-html-sanitizer (~> 1.7) useragent (~> 0.16) actiontext (8.2.0.alpha) action_text-trix (~> 2.1.16) @@ -46,7 +46,7 @@ GIT builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) + rails-html-sanitizer (~> 1.7) activejob (8.2.0.alpha) activesupport (= 8.2.0.alpha) globalid (>= 0.3.6) @@ -103,39 +103,17 @@ GIT GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.16) + action_text-trix (2.1.18) railties - active_link_to (1.0.5) - actionpack - addressable - addressable (2.8.8) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) - anthropic (1.6.0) - connection_pool ast (2.4.3) - avo (3.28.0) - actionview (>= 6.1) - active_link_to - activerecord (>= 6.1) - activesupport (>= 6.1) - addressable - avo-icons (>= 0.1.1) - docile - meta-tags - pagy (>= 7.0.0, < 43) - prop_initializer (>= 0.2.0) - turbo-rails (>= 2.0.0) - turbo_power (>= 0.6.0) - view_component (>= 3.7.0) - zeitwerk (>= 2.6.12) - avo-icons (0.1.1) - inline_svg + babosa (2.0.0) base64 (0.3.0) - bcrypt (3.1.21) bcrypt_pbkdf (1.1.2) - bigdecimal (4.0.1) + bigdecimal (4.1.1) bindex (0.8.1) - bootsnap (1.21.1) + bootsnap (1.23.0) msgpack (~> 1.2) brakeman (8.0.4) racc @@ -160,13 +138,6 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) - devise (4.9.4) - bcrypt (~> 3.0) - orm_adapter (~> 0.1) - railties (>= 4.1.0) - responders - warden (~> 1.2.3) - docile (1.4.1) dotenv (3.2.0) drb (2.2.3) dry-configurable (1.3.0) @@ -183,15 +154,15 @@ GEM concurrent-ruby (~> 1.0) dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-schema (1.15.0) + dry-schema (1.16.0) concurrent-ruby (~> 1.0) dry-configurable (~> 1.0, >= 1.0.1) dry-core (~> 1.1) dry-initializer (~> 3.2) dry-logic (~> 1.6) - dry-types (~> 1.8) + dry-types (~> 1.9, >= 1.9.1) zeitwerk (~> 2.6) - dry-types (1.9.0) + dry-types (1.9.1) bigdecimal (>= 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) @@ -199,12 +170,12 @@ GEM dry-logic (~> 1.4) zeitwerk (~> 2.6) ed25519 (1.4.0) - erb (6.0.1) + erb (6.0.2) erubi (1.13.1) et-orbi (1.4.0) tzinfo event_stream_parser (1.0.0) - faraday (2.14.0) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger @@ -212,6 +183,8 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.2) net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) fast-mcp (1.6.0) addressable (~> 2.8) base64 @@ -219,6 +192,10 @@ GEM json (~> 2.0) mime-types (~> 3.4) rack (>= 2.0, < 4.0) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) friendly_id (5.6.0) activerecord (>= 4.0.0) fugit (1.12.1) @@ -232,8 +209,25 @@ GEM hashdiff (1.2.1) hashie (5.1.0) logger + highline (3.1.2) + reline i18n (1.14.8) concurrent-ruby (~> 1.0) + i18n-tasks (1.1.2) + activesupport (>= 4.0.2) + ast (>= 2.1.0) + erubi + highline (>= 3.0.0) + i18n + parser (>= 3.2.2.1) + prism + rails-i18n + rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.8, >= 1.8.1) + terminal-table (>= 1.5.1) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) + ruby-vips (>= 2.0.17, < 3) importmap-rails (2.2.3) actionpack (>= 6.0.0) activesupport (>= 6.0.0) @@ -242,17 +236,18 @@ GEM activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.8.2) - irb (1.16.0) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (2.18.0) + json (2.19.3) jwt (3.1.2) base64 - kamal (2.10.1) + kamal (2.11.0) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) @@ -276,7 +271,7 @@ GEM kaminari-core (= 1.2.2) kaminari-core (1.2.2) language_server-protocol (3.17.0.5) - lefthook (2.0.16) + lefthook (2.1.5) lint_roller (1.1.0) litestream (0.14.0-aarch64-linux) actionpack (>= 7.0) @@ -300,9 +295,16 @@ GEM railties (>= 7.0) sqlite3 logger (1.7.0) - loofah (2.25.0) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) + madmin (2.3.2) + importmap-rails + pagy (>= 3.5) + propshaft + rails (>= 7.0.0) + stimulus-rails + turbo-rails mail (2.9.0) logger mini_mime (>= 0.1.1) @@ -312,22 +314,26 @@ GEM marcel (1.1.0) matrix (0.4.3) maxminddb (0.1.22) - meta-tags (2.22.3) - actionpack (>= 6.0.0) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2026.0127) + mime-types-data (3.2026.0331) + mini_magick (5.3.1) + logger mini_mime (1.1.5) - minitest (6.0.1) + minitest (6.0.3) + drb (~> 2.0) prism (~> 1.5) + mobility (1.3.2) + i18n (>= 0.6.10, < 2) + request_store (~> 1.0) msgpack (1.8.0) multi_xml (0.8.1) bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) net-http (0.9.1) uri (>= 0.11.1) - net-imap (0.6.2) + net-imap (0.6.3) date net-protocol net-pop (0.1.2) @@ -340,15 +346,15 @@ GEM net-ssh (>= 5.0.0, < 8.0.0) net-smtp (0.5.1) net-protocol - net-ssh (7.3.0) + net-ssh (7.3.2) nio4r (2.7.5) - nokogiri (1.19.0-aarch64-linux-gnu) + nokogiri (1.19.2-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-arm64-darwin) + nokogiri (1.19.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-gnu) + nokogiri (1.19.2-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-musl) + nokogiri (1.19.2-x86_64-linux-musl) racc (~> 1.4) oauth2 (2.0.18) faraday (>= 0.17.3, < 4.0) @@ -369,22 +375,22 @@ GEM omniauth-oauth2 (1.9.0) oauth2 (>= 2.0.2, < 3) omniauth (~> 2.0) - omniauth-rails_csrf_protection (1.0.2) + omniauth-rails_csrf_protection (2.0.1) actionpack (>= 4.2) omniauth (~> 2.0) - orm_adapter (0.5.0) ostruct (0.6.3) - pagy (9.4.0) - parallel (1.27.0) - parser (3.3.10.1) + pagy (43.4.4) + json + uri + yaml + parallel (1.28.0) + parser (3.3.11.1) ast (~> 2.4.1) racc pp (0.6.3) prettyprint prettyprint (0.2.0) prism (1.9.0) - prop_initializer (0.2.0) - zeitwerk (>= 2.6.18) propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -392,12 +398,12 @@ GEM psych (5.3.1) date stringio - public_suffix (7.0.2) + public_suffix (7.0.5) puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.4) + rack (3.2.6) rack-protection (4.2.1) base64 (>= 0.1.0) logger (>= 1.6.0) @@ -413,26 +419,28 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-i18n (8.1.0) + i18n (>= 0.7, < 2) + railties (>= 8.0.0, < 9) rainbow (3.1.1) rake (13.3.1) - rdoc (7.1.0) + rdoc (7.2.0) erb psych (>= 4.0.0) tsort redcarpet (3.6.1) - regexp_parser (2.11.3) + regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) - responders (3.2.0) - actionpack (>= 7.0) - railties (>= 7.0) + request_store (1.7.0) + rack (>= 1.4) rexml (3.4.4) - rorvswild (1.10.1) + rorvswild (1.11.1) rouge (4.7.0) - rubocop (1.84.0) + rubocop (1.86.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -443,7 +451,7 @@ GEM rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) rubocop-performance (1.26.1) @@ -460,14 +468,24 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - ruby-openai (8.3.0) - event_stream_parser (>= 0.3.0, < 2.0.0) - faraday (>= 1) - faraday-multipart (>= 1) ruby-progressbar (1.13.0) + ruby-vips (2.3.0) + ffi (~> 1.12) + logger + ruby_llm (1.14.1) + base64 + event_stream_parser (~> 1) + faraday (>= 1.10.0) + faraday-multipart (>= 1) + faraday-net_http (>= 1) + faraday-retry (>= 1) + marcel (~> 1) + ruby_llm-schema (~> 0) + zeitwerk (~> 2) + ruby_llm-schema (0.3.0) rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.40.0) + selenium-webdriver (4.41.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -485,7 +503,7 @@ GEM activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.3.1) + solid_queue (1.4.0) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) @@ -496,10 +514,10 @@ GEM sqlean (0.3.0-arm64-darwin) sqlean (0.3.0-x86_64-linux-gnu) sqlean (0.3.0-x86_64-linux-musl) - sqlite3 (2.9.0-aarch64-linux-gnu) - sqlite3 (2.9.0-arm64-darwin) - sqlite3 (2.9.0-x86_64-linux-gnu) - sqlite3 (2.9.0-x86_64-linux-musl) + sqlite3 (2.9.2-aarch64-linux-gnu) + sqlite3 (2.9.2-arm64-darwin) + sqlite3 (2.9.2-x86_64-linux-gnu) + sqlite3 (2.9.2-x86_64-linux-musl) sshkit (1.25.0) base64 logger @@ -510,28 +528,31 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) + stripe (19.0.0) + bigdecimal + logger tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.1.18-aarch64-linux-gnu) - tailwindcss-ruby (4.1.18-arm64-darwin) - tailwindcss-ruby (4.1.18-x86_64-linux-gnu) - tailwindcss-ruby (4.1.18-x86_64-linux-musl) + tailwindcss-ruby (4.2.1-aarch64-linux-gnu) + tailwindcss-ruby (4.2.1-arm64-darwin) + tailwindcss-ruby (4.2.1-x86_64-linux-gnu) + tailwindcss-ruby (4.2.1-x86_64-linux-musl) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) thor (1.5.0) - thruster (0.1.17-aarch64-linux) - thruster (0.1.17-arm64-darwin) - thruster (0.1.17-x86_64-linux) - tidewave (0.4.1) + thruster (0.1.20-aarch64-linux) + thruster (0.1.20-arm64-darwin) + thruster (0.1.20-x86_64-linux) + tidewave (0.4.2) fast-mcp (~> 1.6.0) rack (>= 2.0) rails (>= 7.1.0) - timeout (0.6.0) + timeout (0.6.1) tsort (0.2.0) turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) - turbo_power (0.7.0) - turbo-rails (>= 1.3.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) @@ -540,18 +561,11 @@ GEM uri (1.1.1) useragent (0.16.11) version_gem (1.1.9) - view_component (4.2.0) - actionview (>= 7.1.0) - activesupport (>= 7.1.0) - concurrent-ruby (~> 1) - warden (1.2.9) - rack (>= 2.0.9) - web-console (4.2.1) - actionview (>= 6.0.0) - activemodel (>= 6.0.0) + web-console (4.3.0) + actionview (>= 8.0.0) bindex (>= 0.4.0) - railties (>= 6.0.0) - webmock (3.26.1) + railties (>= 8.0.0) + webmock (3.26.2) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -563,7 +577,8 @@ GEM wheretz (0.0.6) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.4) + yaml (0.4.0) + zeitwerk (2.7.5) PLATFORMS aarch64-linux @@ -574,34 +589,38 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES - anthropic (~> 1.6.0) - avo (>= 3.2) + babosa bootsnap brakeman capybara debug - devise (~> 4.9) dotenv - friendly_id (~> 5.5) - geocoder (~> 1.8) + fast-mcp (~> 1.6) + friendly_id + geocoder + i18n-tasks (~> 1.0) + image_processing (~> 1.2) importmap-rails + inline_svg jbuilder kamal kaminari (~> 1.2) lefthook - litestream (~> 0.14.0) - maxminddb (~> 0.1) + litestream + madmin (~> 2.1) + maxminddb + mobility (~> 1.3) nokogiri (~> 1.16) omniauth-github (~> 2.0) - omniauth-rails_csrf_protection (~> 1.0) + omniauth-rails_csrf_protection (~> 2.0) propshaft puma (>= 5.0) rails! - redcarpet (~> 3.6) + redcarpet rorvswild (~> 1.9) - rouge (~> 4.0) + rouge rubocop-rails-omakase - ruby-openai (~> 8.2) + ruby_llm (~> 1.9) selenium-webdriver solid_cable solid_cache @@ -609,6 +628,7 @@ DEPENDENCIES sqlean (~> 0.2) sqlite3 (>= 2.1) stimulus-rails + stripe tailwindcss-rails (~> 4.0) thruster tidewave (~> 0.4.1) @@ -618,30 +638,26 @@ DEPENDENCIES wheretz CHECKSUMS - action_text-trix (2.1.16) sha256=f645a2c21821b8449fd1d6770708f4031c91a2eedf9ef476e9be93c64e703a8a + action_text-trix (2.1.18) sha256=3fdb83f8bff4145d098be283cdd47ac41caf5110bfa6df4695ed7127d7fb3642 actioncable (8.2.0.alpha) actionmailbox (8.2.0.alpha) actionmailer (8.2.0.alpha) actionpack (8.2.0.alpha) actiontext (8.2.0.alpha) actionview (8.2.0.alpha) - active_link_to (1.0.5) sha256=4830847b3d14589df1e9fc62038ceec015257fce975ec1c2a77836c461b139ba activejob (8.2.0.alpha) activemodel (8.2.0.alpha) activerecord (8.2.0.alpha) activestorage (8.2.0.alpha) activesupport (8.2.0.alpha) - addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 - anthropic (1.6.0) sha256=61fa13d73f54d8174bf8b45cc058768e79e824ebc2aefc9417a2b94d9127ab75 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 - avo (3.28.0) sha256=9a7ab701f41ee201b87553a36f0d34d4fd03a7c7737aeba6c0da09ba5a031910 - avo-icons (0.1.1) sha256=d9a23d6d47bb7f8f04163119352a66a436dc8accf53f15cd0c3b5fcaffed082c + babosa (2.0.0) sha256=a6218db8a4dc8fd99260dde8bc3d5fa1a0c52178196e236ebb31e41fbdcdb8a6 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b - bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 - bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + bigdecimal (4.1.1) sha256=1c09efab961da45203c8316b0cdaec0ff391dfadb952dd459584b63ebf8054ca bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e - bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4 + bootsnap (1.23.0) sha256=c1254f458d58558b58be0f8eb8f6eec2821456785b7cdd1e16248e2020d3f214 brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef @@ -652,8 +668,6 @@ CHECKSUMS csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 - devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 - docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 dry-configurable (1.3.0) sha256=882d862858567fc1210d2549d4c090f34370fc1bb7c5c1933de3fe792e18afa8 @@ -661,163 +675,174 @@ CHECKSUMS dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc dry-initializer (3.2.0) sha256=37d59798f912dc0a1efe14a4db4a9306989007b302dcd5f25d0a2a20c166c4e3 dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 - dry-schema (1.15.0) sha256=0f2a34adba4206bd6d46ec1b6b7691b402e198eecaff1d8349a7d48a77d82cd2 - dry-types (1.9.0) sha256=7b656fe0a78d2432500ae1f29fefd6762f5a032ca7000e4f36bc111453d45d4d + dry-schema (1.16.0) sha256=cd3aaeabc0f1af66ec82a29096d4c4fb92a0a58b9dae29a22b1bbceb78985727 + dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 - erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5 + erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc event_stream_parser (1.0.0) sha256=a2683bab70126286f8184dc88f7968ffc4028f813161fb073ec90d171f7de3c8 - faraday (2.14.0) sha256=8699cfe5d97e55268f2596f9a9d5a43736808a943714e3d9a53e6110593941cd + faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe fast-mcp (1.6.0) sha256=d68abb45d2daab9e7ae2934417460e4bf9ac87493c585dc5bb626f1afb7d12c4 + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b + ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e friendly_id (5.6.0) sha256=28e221cd53fbd21586321164c1c6fd0c9ba8dde13969cb2363679f44726bb0c3 fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 geocoder (1.8.6) sha256=e0ca1554b499f466de9b003f7dff70f89a5888761c2ca68ed9f86b6e5e24e74c globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870 + highline (3.1.2) sha256=67cbd34d19f6ef11a7ee1d82ffab5d36dfd5b3be861f450fc1716c7125f4bb4a i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + i18n-tasks (1.1.2) sha256=4dcfba49e52a623f30661cb316cb80d84fbba5cb8c6d88ef5e02545fffa3637a + image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc - irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806 + irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 - json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505 + json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646 jwt (3.1.2) sha256=af6991f19a6bb4060d618d9add7a66f0eeb005ac0bc017cd01f63b42e122d535 - kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340 + kamal (2.11.0) sha256=1408864425e0dec7e0a14d712a3b13f614e9f3a425b7661d3f9d287a51d7dd75 kaminari (1.2.2) sha256=c4076ff9adccc6109408333f87b5c4abbda5e39dc464bd4c66d06d9f73442a3e kaminari-actionview (1.2.2) sha256=1330f6fc8b59a4a4ef6a549ff8a224797289ebf7a3a503e8c1652535287cc909 kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430 kaminari-core (1.2.2) sha256=3bd26fec7370645af40ca73b9426a448d09b8a8ba7afa9ba3c3e0d39cdbb83ff language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc - lefthook (2.0.16) sha256=c23ac3732ef9e7c6e9db4bc97bc4813d1a648db5d00e0f70cca426d04f3edf2e + lefthook (2.1.5) sha256=24bd602992e1ec36057bcc667bb74f23918ed05c715050a44e5a156f0b7a4312 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 litestream (0.14.0-aarch64-linux) sha256=bcf9199a665e673e27f929a0941011e50fb8ebf441d9754247686b514fba60d5 litestream (0.14.0-arm64-darwin) sha256=507bbb7ee99b3398304c5ef4a9bae835761359ffc72850f25708477805313d07 litestream (0.14.0-x86_64-linux) sha256=2844734b6d8e5c6009baf8d138d6f18367f770e9e4390fb70763433db587bed6 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 - loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 + loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 + madmin (2.3.2) sha256=6961cbaeed82634240c7c9888a49b181834bf9b85a9282caebf0ee7f368df73c mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b maxminddb (0.1.22) sha256=50933be438fbed9dceabef4163eab41884bd8830d171fdb8f739bee769c4907e - meta-tags (2.22.3) sha256=41ead5437140869717cbdd659cc6f1caa3e498b3e74b03ed63503b5b38ed504f mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 - mime-types-data (3.2026.0127) sha256=4a58692436a987ad930e75bf8f24da7e627acfa0d06e1720aa514791b4c7d12b + mime-types-data (3.2026.0331) sha256=e9942b1fac72532e2b201b0c32c52e7650ef5ef8ca043a5054674597795c97a5 + mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef - minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb + minitest (6.0.3) sha256=88ac8a1de36c00692420e7cb3cc11a0773bbcb126aee1c249f320160a7d11411 + mobility (1.3.2) sha256=32fbbb0e53118ef42de20daa6ac94dbb758c628874092eba311b968a1e1d757b msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 multi_xml (0.8.1) sha256=addba0290bac34e9088bfe73dc4878530297a82a7bbd66cb44dcd0a4b86edf5a multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 - net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 + net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 - net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0 + net-ssh (7.3.2) sha256=65029e213c380e20e5fd92ece663934ab0a0fe888e0cd7cc6a5b664074362dd4 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 - nokogiri (1.19.0-aarch64-linux-gnu) sha256=11a97ecc3c0e7e5edcf395720b10860ef493b768f6aa80c539573530bc933767 - nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810 - nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c - nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4 + nokogiri (1.19.2-aarch64-linux-gnu) sha256=c34d5c8208025587554608e98fd88ab125b29c80f9352b821964e9a5d5cfbd19 + nokogiri (1.19.2-arm64-darwin) sha256=58d8ea2e31a967b843b70487a44c14c8ba1866daa1b9da9be9dbdf1b43dee205 + nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f + nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8 oauth2 (2.0.18) sha256=bacf11e470dfb963f17348666d0a75c7b29ca65bc48fd47be9057cf91a403287 omniauth (2.1.4) sha256=42a05b0496f0d22e1dd85d42aaf602f064e36bb47a6826a27ab55e5ba608763c omniauth-github (2.0.1) sha256=8ff8e70ac6d6db9d52485eef52cfa894938c941496e66b52b5e2773ade3ccad4 omniauth-oauth2 (1.9.0) sha256=ed15f6d9d20991807ce114cc5b9c1453bce3645b64e51c68c90cff5ff153fee8 - omniauth-rails_csrf_protection (1.0.2) sha256=1170fd672aff092b9b7ebebc1453559f073ed001e3ce62a1df616e32f8dc5fe0 - orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9 + omniauth-rails_csrf_protection (2.0.1) sha256=c6e3204d7e3925bb537cb52d50fdfc9f05293f1a9d87c5d4ab4ca3a39ba8c32d ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 - pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 - parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 - parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688 + pagy (43.4.4) sha256=b41a57328a0aabfd222266a89e9de3dc3a735c17bd57f8113829c95fece5bef6 + parallel (1.28.0) sha256=33e6de1484baf2524792d178b0913fc8eb94c628d6cfe45599ad4458c638c970 + parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 - prop_initializer (0.2.0) sha256=bd27704d0df8c59c3baf0df5cf448eba2b140fb9934fb31b2e379b5c842d8820 propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392 psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 - public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8 raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6 + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 rails (8.2.0.alpha) rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d - rails-html-sanitizer (1.6.2) sha256=35fce2ca8242da8775c83b6ba9c1bcaad6751d9eb73c1abaa8403475ab89a560 + rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 + rails-i18n (8.1.0) sha256=52d5fd6c0abef28d84223cc05647f6ae0fd552637a1ede92deee9545755b6cf3 railties (8.2.0.alpha) rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c - rdoc (7.1.0) sha256=494899df0706c178596ca6e1d50f1b7eb285a9b2aae715be5abd742734f17363 + rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 redcarpet (3.6.1) sha256=d444910e6aa55480c6bcdc0cdb057626e8a32c054c29e793fa642ba2f155f445 - regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 - responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a + request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 - rorvswild (1.10.1) sha256=eb3f64611e48661f275747934961bf8585315702ee8913f220ccae3e65ac3f56 + rorvswild (1.11.1) sha256=f2e2ade02c375e709cdd3c9f7f21601fb17db09399ab2a2f47fde72aa0609015 rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739 - rubocop (1.84.0) sha256=88dec310153bb685a879f5a7cdb601f6287b8f0ee675d9dc63a17c7204c4190a - rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + rubocop (1.86.0) sha256=4ff1186fe16ebe9baff5e7aad66bb0ad4cabf5cdcd419f773146dbba2565d186 + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2 rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d - ruby-openai (8.3.0) sha256=566dc279c42f4afed68a7a363dce2e594078abfc36b4e043102020b9a387ca69 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + ruby_llm (1.14.1) sha256=7487d0f0bb9e86836d9233d656e10637370a6b22eb2555343c0a4c179ce7c500 + ruby_llm-schema (0.3.0) sha256=a591edc5ca1b7f0304f0e2261de61ba4b3bea17be09f5cf7558153adfda3dec6 rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - selenium-webdriver (4.40.0) sha256=16ef7aa9853c1d4b9d52eac45aafa916e3934c5c83cb4facb03f250adfd15e5b + selenium-webdriver (4.41.0) sha256=cdc1173cd55cf186022cea83156cc2d0bec06d337e039b02ad25d94e41bedd22 snaky_hash (2.0.3) sha256=25a3d299566e8153fb02fa23fd9a9358845950f7a523ddbbe1fa1e0d79a6d456 solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 - solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428 + solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a sqlean (0.3.0-aarch64-linux-gnu) sha256=2b88dcefd7c9a92a9287c1bf8d650f286275d2645c95d5836c2efe8a0255a078 sqlean (0.3.0-arm64-darwin) sha256=32ffa1e5a908a52c028fb06fa2dbe61f600a865c95960d7ec4f3fbc82f28bf78 sqlean (0.3.0-x86_64-linux-gnu) sha256=51e7e0a66ceebf26c4a4509001412bea2214fb748752fde96a228db9cb2e85ce sqlean (0.3.0-x86_64-linux-musl) sha256=93eb4f18679539b64c478dd2d57e393404c036e0057a0a93b2a8089ea6caa94a - sqlite3 (2.9.0-aarch64-linux-gnu) sha256=cfe1e0216f46d7483839719bf827129151e6c680317b99d7b8fc1597a3e13473 - sqlite3 (2.9.0-arm64-darwin) sha256=a917bd9b84285766ff3300b7d79cd583f5a067594c8c1263e6441618c04a6ed3 - sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5 - sqlite3 (2.9.0-x86_64-linux-musl) sha256=ef716ba7a66d7deb1ccc402ac3a6d7343da17fac862793b7f0be3d2917253c90 + sqlite3 (2.9.2-aarch64-linux-gnu) sha256=eeb86db55645b85327ba75129e3614658d974bf4da8fdc87018a0d42c59f6e42 + sqlite3 (2.9.2-arm64-darwin) sha256=d15bd9609a05f9d54930babe039585efc8cadd57517c15b64ec7dfa75158a5e9 + sqlite3 (2.9.2-x86_64-linux-gnu) sha256=dce83ffcb7e72f9f7aeb6e5404f15d277a45332fe18ccce8a8b3ed51e8d23aee + sqlite3 (2.9.2-x86_64-linux-musl) sha256=e8dd906a613f13b60f6d47ae9dda376384d9de1ab3f7e3f2fdf2fd18a871a2d7 sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + stripe (19.0.0) sha256=dc8cf11700638e6d0c0ca10063e7829ee90b26feb8e87811dcbbffc31645a656 tailwindcss-rails (4.4.0) sha256=efa2961351a52acebe616e645a81a30bb4f27fde46cc06ce7688d1cd1131e916 - tailwindcss-ruby (4.1.18-aarch64-linux-gnu) sha256=e10f9560bccddbb4955fd535b3bcc8c7071a7df07404dd473a23fa791ec4e46b - tailwindcss-ruby (4.1.18-arm64-darwin) sha256=f940531d5a030c566d3d616004235bcd4c361abdd328f7d6c7e3a953a32e0155 - tailwindcss-ruby (4.1.18-x86_64-linux-gnu) sha256=e0a2220163246fe0126c5c5bafb95bc6206e7d21fce2a2878fd9c9a359137534 - tailwindcss-ruby (4.1.18-x86_64-linux-musl) sha256=d957cf545b09d2db7eb6267450cc1fc589e126524066537a0c4d5b99d701f4b2 + tailwindcss-ruby (4.2.1-aarch64-linux-gnu) sha256=de457ddfc999c6bbbe1a59fbc11eb2168d619f6e0cb72d8d3334d372b331e36f + tailwindcss-ruby (4.2.1-arm64-darwin) sha256=bcf222fb8542cf5433925623e5e7b257897fbb8291a2350daae870a32f2eeb91 + tailwindcss-ruby (4.2.1-x86_64-linux-gnu) sha256=201d0e5e5d4aba52cae4ee4bd1acd497d2790c83e7f15da964aab8ec93876831 + tailwindcss-ruby (4.2.1-x86_64-linux-musl) sha256=79fa48ad51e533545f9fdbb04227e1342a65a42c2bd1314118b95473d5612007 + terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 - thruster (0.1.17-aarch64-linux) sha256=1b3a34b2814185c2aeaf835b5ecff5348cdcf8e77809f7a092d46e4b962a16ba - thruster (0.1.17-arm64-darwin) sha256=75da66fc4a0f012f9a317f6362f786a3fa953879a3fa6bed8deeaebf1c1d66ec - thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3 - tidewave (0.4.1) sha256=e33e0b5bd8678825fa00f2703ca64754d910996682f78b3420499068bc123258 - timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af + thruster (0.1.20-aarch64-linux) sha256=754f1701061235235165dde31e7a3bc87ec88066a02981ff4241fcda0d76d397 + thruster (0.1.20-arm64-darwin) sha256=630cf8c273f562063b92ea5ccd7a721d7ba6130cc422c823727f4744f6d0770e + thruster (0.1.20-x86_64-linux) sha256=d579f252bf67aee6ba6d957e48f566b72e019d7657ba2f267a5db1e4d91d2479 + tidewave (0.4.2) sha256=e2c58ca43fa0b0d87f9825ab07f06add43e9ad8cf7c5aaa7b1166138d7b52bb8 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f - turbo_power (0.7.0) sha256=ad95d147e0fa761d0023ad9ca00528c7b7ddf6bba8ca2e23755d5b21b290d967 tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 version_gem (1.1.9) sha256=0c1a0962ae543c84a00889bb018d9f14d8f8af6029d26b295d98774e3d2eb9a4 - view_component (4.2.0) sha256=f250a3397a794336354f73c229b3b7549af0b24906551b99a03492b54cb5233d - warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 - web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 - webmock (3.26.1) sha256=4f696fb57c90a827c20aadb2d4f9058bbff10f7f043bd0d4c3f58791143b1cd7 + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90 websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 wheretz (0.0.6) sha256=3ac9fa92aa4ff20c2b5e292f6ed041c9915a87ed5ddbf486cc94652a5554a0c7 xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e - zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b + yaml (0.4.0) sha256=240e69d1e6ce3584d6085978719a0faa6218ae426e034d8f9b02fb54d3471942 + zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd BUNDLED WITH 4.0.5 diff --git a/README.md b/README.md index 5604794..c88fbfd 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ Access the admin panel at `/admin` ### Background Jobs - `GenerateSummaryJob`: Creates AI summaries for new content -- `GenerateTestimonialFieldsJob`: AI-generates headline, subheadline, and quote from testimonials +- `GenerateTestimonialJob`: AI-generates headline, subheadline, and quote from testimonials - `ValidateTestimonialJob`: LLM-validates testimonial content - `UpdateGithubDataJob`: Refreshes user GitHub data via GraphQL API - `NormalizeLocationJob`: Geocodes user locations for the community map diff --git a/app/assets/images/capedbot.svg b/app/assets/images/capedbot.svg new file mode 100755 index 0000000..f613886 --- /dev/null +++ b/app/assets/images/capedbot.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/images/icons/arrow-up.svg b/app/assets/images/icons/arrow-up.svg new file mode 100644 index 0000000..00eed24 --- /dev/null +++ b/app/assets/images/icons/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/chart.svg b/app/assets/images/icons/chart.svg new file mode 100644 index 0000000..156d57e --- /dev/null +++ b/app/assets/images/icons/chart.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/chat.svg b/app/assets/images/icons/chat.svg new file mode 100644 index 0000000..74f7a43 --- /dev/null +++ b/app/assets/images/icons/chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/check-circle.svg b/app/assets/images/icons/check-circle.svg new file mode 100644 index 0000000..309b5bc --- /dev/null +++ b/app/assets/images/icons/check-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/chevron-down.svg b/app/assets/images/icons/chevron-down.svg new file mode 100644 index 0000000..3664435 --- /dev/null +++ b/app/assets/images/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/chevron-left.svg b/app/assets/images/icons/chevron-left.svg new file mode 100644 index 0000000..98100ed --- /dev/null +++ b/app/assets/images/icons/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/chevron-right.svg b/app/assets/images/icons/chevron-right.svg new file mode 100644 index 0000000..7e3f638 --- /dev/null +++ b/app/assets/images/icons/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/cog.svg b/app/assets/images/icons/cog.svg new file mode 100644 index 0000000..0fb9a8a --- /dev/null +++ b/app/assets/images/icons/cog.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/icons/computer.svg b/app/assets/images/icons/computer.svg new file mode 100644 index 0000000..1ae3485 --- /dev/null +++ b/app/assets/images/icons/computer.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/credit-card.svg b/app/assets/images/icons/credit-card.svg new file mode 100644 index 0000000..bfae2ab --- /dev/null +++ b/app/assets/images/icons/credit-card.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/cube.svg b/app/assets/images/icons/cube.svg new file mode 100644 index 0000000..c77bf87 --- /dev/null +++ b/app/assets/images/icons/cube.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/dashboard.svg b/app/assets/images/icons/dashboard.svg new file mode 100644 index 0000000..5ffd3b3 --- /dev/null +++ b/app/assets/images/icons/dashboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/dollar.svg b/app/assets/images/icons/dollar.svg new file mode 100644 index 0000000..9448265 --- /dev/null +++ b/app/assets/images/icons/dollar.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/external-link.svg b/app/assets/images/icons/external-link.svg new file mode 100644 index 0000000..4f1e3ae --- /dev/null +++ b/app/assets/images/icons/external-link.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/file.svg b/app/assets/images/icons/file.svg new file mode 100644 index 0000000..8d85994 --- /dev/null +++ b/app/assets/images/icons/file.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/icons/folder.svg b/app/assets/images/icons/folder.svg new file mode 100644 index 0000000..2ebc432 --- /dev/null +++ b/app/assets/images/icons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/github.svg b/app/assets/images/icons/github.svg new file mode 100644 index 0000000..06d8ba0 --- /dev/null +++ b/app/assets/images/icons/github.svg @@ -0,0 +1,3 @@ + diff --git a/app/assets/images/icons/globe.svg b/app/assets/images/icons/globe.svg new file mode 100644 index 0000000..c2b862d --- /dev/null +++ b/app/assets/images/icons/globe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/icons/home.svg b/app/assets/images/icons/home.svg new file mode 100644 index 0000000..cd9b17d --- /dev/null +++ b/app/assets/images/icons/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/image.svg b/app/assets/images/icons/image.svg new file mode 100644 index 0000000..84d5515 --- /dev/null +++ b/app/assets/images/icons/image.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/icons/key.svg b/app/assets/images/icons/key.svg new file mode 100644 index 0000000..2d03609 --- /dev/null +++ b/app/assets/images/icons/key.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/icons/lightning.svg b/app/assets/images/icons/lightning.svg new file mode 100644 index 0000000..f8aa050 --- /dev/null +++ b/app/assets/images/icons/lightning.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/mail.svg b/app/assets/images/icons/mail.svg new file mode 100644 index 0000000..86ca289 --- /dev/null +++ b/app/assets/images/icons/mail.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/icons/menu.svg b/app/assets/images/icons/menu.svg new file mode 100644 index 0000000..2d22fc9 --- /dev/null +++ b/app/assets/images/icons/menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/messages.svg b/app/assets/images/icons/messages.svg new file mode 100644 index 0000000..29cfeb7 --- /dev/null +++ b/app/assets/images/icons/messages.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/paperclip.svg b/app/assets/images/icons/paperclip.svg new file mode 100644 index 0000000..8d20c4e --- /dev/null +++ b/app/assets/images/icons/paperclip.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/icons/pencil.svg b/app/assets/images/icons/pencil.svg new file mode 100644 index 0000000..ee2a66d --- /dev/null +++ b/app/assets/images/icons/pencil.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/plus.svg b/app/assets/images/icons/plus.svg new file mode 100644 index 0000000..91822b8 --- /dev/null +++ b/app/assets/images/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/shield.svg b/app/assets/images/icons/shield.svg new file mode 100644 index 0000000..60b7935 --- /dev/null +++ b/app/assets/images/icons/shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/sign-out.svg b/app/assets/images/icons/sign-out.svg new file mode 100644 index 0000000..1935405 --- /dev/null +++ b/app/assets/images/icons/sign-out.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/spinner.svg b/app/assets/images/icons/spinner.svg new file mode 100644 index 0000000..d47c806 --- /dev/null +++ b/app/assets/images/icons/spinner.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/icons/trash.svg b/app/assets/images/icons/trash.svg new file mode 100644 index 0000000..49d9b6e --- /dev/null +++ b/app/assets/images/icons/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/user.svg b/app/assets/images/icons/user.svg new file mode 100644 index 0000000..0500586 --- /dev/null +++ b/app/assets/images/icons/user.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/icons/users.svg b/app/assets/images/icons/users.svg new file mode 100644 index 0000000..91a336b --- /dev/null +++ b/app/assets/images/icons/users.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/wrench.svg b/app/assets/images/icons/wrench.svg new file mode 100644 index 0000000..83e06fc --- /dev/null +++ b/app/assets/images/icons/wrench.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/x-circle.svg b/app/assets/images/icons/x-circle.svg new file mode 100644 index 0000000..709c738 --- /dev/null +++ b/app/assets/images/icons/x-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/x.svg b/app/assets/images/icons/x.svg new file mode 100644 index 0000000..6b8ab35 --- /dev/null +++ b/app/assets/images/icons/x.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 6f4967a..bb12afb 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -2,115 +2,789 @@ @plugin "@tailwindcss/forms"; @plugin "@tailwindcss/typography"; +/* ================================ + WhyRuby-specific Components (light theme, public pages) + ================================ */ @layer components { - /* Prose styles for Markdown content */ - .prose h1 { - @apply text-3xl font-bold mb-4 mt-8; + .prose h1 { @apply text-3xl font-bold mb-4 mt-8; } + .prose h2 { @apply text-2xl font-bold mb-3 mt-6; } + .prose h3 { @apply text-xl font-bold mb-2 mt-4; } + .prose h4 { @apply uppercase; } + .prose p { @apply mb-4; } + .prose ul { @apply list-disc list-inside mb-4; } + .prose ol { @apply list-decimal list-inside mb-4; } + .prose li { @apply mb-1; } + .prose code { @apply bg-gray-100 px-1 py-0.5 rounded text-sm font-mono; } + .prose pre { @apply bg-gray-100 p-4 rounded overflow-x-auto mb-4; } + .prose pre code { @apply bg-transparent p-0; } + .prose blockquote { @apply border-l-4 border-gray-300 pl-4 italic mb-4; } + .prose a { @apply text-red-600 hover:text-red-800 underline; } + .prose strong { @apply font-bold; } + .prose em { @apply italic; } +} + +@layer utilities { + /* Sticky header for tablets/desktops */ + .sticky-tablet { position: static; } + + @media (min-width: 768px) and (min-height: 600px) and (orientation: portrait) { + .sticky-tablet { position: sticky; } } - .prose h2 { - @apply text-2xl font-bold mb-3 mt-6; + @media (min-width: 1400px) and (min-height: 600px) { + .sticky-tablet { position: sticky; } } - .prose h3 { - @apply text-xl font-bold mb-2 mt-4; + + /* Shorter map on landscape tablets */ + @media (orientation: landscape) and (min-width: 768px) and (max-width: 1399px) { + .community-map-landscape { height: 400px; } } - .prose h4 { - @apply uppercase; + + @keyframes highlight-blink { + 0% { background-color: theme(colors.red.200); color: theme(colors.gray.900); } + 50% { background-color: theme(colors.red.200); color: theme(colors.gray.900); } + 100% { background-color: theme(colors.gray.50); color: inherit; } } - .prose p { - @apply mb-4; + + .comment-highlight:target .comment-content, + .comment-highlighting { + animation: highlight-blink 1s ease-in-out; } - .prose ul { - @apply list-disc list-inside mb-4; + + /* Override Tailwind Typography backtick decorations for inline code */ + .prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before, + .prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after, + .prose-sm :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before, + .prose-sm :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after, + .prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before, + .prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after, + .md\:prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before, + .md\:prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after { + content: none !important; } - .prose ol { - @apply list-decimal list-inside mb-4; +} + +/* Dark mode variant (class-based) */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* ================================ + Theme configuration - OKLCH colors + fonts + (Tailwind utilities like bg-dark-500, text-accent-400, etc.) + ================================ */ +@theme { + --font-sans: "Inter var", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + + /* Dark theme palette - neutral greys (OKLCH) */ + --color-dark-50: oklch(98% 0.005 250); + --color-dark-100: oklch(96% 0.005 250); + --color-dark-200: oklch(93% 0.005 250); + --color-dark-300: oklch(89% 0.005 250); + --color-dark-400: oklch(73% 0.01 250); + --color-dark-500: oklch(52% 0.015 250); + --color-dark-600: oklch(40% 0.015 250); + --color-dark-700: oklch(30% 0.015 250); + --color-dark-800: oklch(21% 0.015 250); + --color-dark-850: oklch(19% 0.015 250); + --color-dark-900: oklch(14% 0.01 250); + --color-dark-950: oklch(10% 0.01 250); + + /* Accent colors (OKLCH) */ + --color-accent-50: oklch(97% 0.02 260); + --color-accent-100: oklch(93% 0.05 260); + --color-accent-200: oklch(87% 0.08 260); + --color-accent-300: oklch(78% 0.13 260); + --color-accent-400: oklch(70% 0.17 260); + --color-accent-500: oklch(62% 0.21 260); + --color-accent-600: oklch(55% 0.22 260); + --color-accent-700: oklch(48% 0.22 260); +} + +/* ================================ + Madmin Dark Theme (all styles scoped under .dark) + ================================ */ + +.body-bg { + @apply bg-dark-800 text-dark-100 min-h-screen; +} + +.card-bg { + @apply bg-dark-700/50 rounded-xl shadow-lg shadow-black/10; +} + +.select-transparent { + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + appearance: none !important; + background-image: none !important; + + .dark &.text-dark-400 { + color: var(--color-dark-400) !important; } - .prose li { - @apply mb-1; +} + +/* Dark-prefixed utility classes (only used in Madmin templates) */ +.dark-card { + background-color: var(--color-dark-700); + border: 1px solid var(--color-dark-600); + border-radius: 0.75rem; + + &-header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--color-dark-600); } - .prose code { - @apply bg-gray-100 px-1 py-0.5 rounded text-sm font-mono; + + &-body { + padding: 1.5rem; } +} + +.dark-table { + width: 100%; + border-collapse: collapse; - .prose pre { - @apply bg-gray-100 p-4 rounded overflow-x-auto mb-4; + th { + background-color: var(--color-dark-700); + color: var(--color-dark-300); + font-weight: 500; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--color-dark-600); } - .prose pre code { - @apply bg-transparent p-0; + + td { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-dark-700); + color: var(--color-dark-200); } - .prose blockquote { - @apply border-l-4 border-gray-300 pl-4 italic mb-4; + + tbody tr:hover { + background-color: var(--color-dark-700); } - .prose a { - @apply text-red-600 hover:text-red-800 underline; +} + +.btn-dark { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 0.5rem; + transition: all 0.15s ease; + + &-primary { + background: linear-gradient(135deg, oklch(62% 0.21 260) 0%, oklch(55% 0.22 260) 100%); + color: white; + border: none; + + &:hover { + background: linear-gradient(135deg, oklch(55% 0.22 260) 0%, oklch(48% 0.22 260) 100%); + transform: translateY(-1px); + } } - .prose strong { - @apply font-bold; + + &-secondary { + background-color: var(--color-dark-700); + color: var(--color-dark-200); + border: 1px solid var(--color-dark-500); + + &:hover { + background-color: var(--color-dark-600); + border-color: var(--color-dark-400); + } } - .prose em { - @apply italic; + + &-danger { + background-color: oklch(62% 0.24 25 / 10%); + color: oklch(70% 0.19 25); + border: 1px solid oklch(62% 0.24 25 / 30%); + + &:hover { + background-color: oklch(62% 0.24 25 / 20%); + border-color: oklch(62% 0.24 25 / 50%); + } } } -@layer utilities { - /* Sticky header: not on phones, not on horizontal tablets */ - /* Horizontal phones: height < 600px → excluded */ - /* Horizontal iPads: landscape with width ≤ 1366px → excluded (all iPads max out at 1366px CSS width) */ - /* Desktops/laptops: typically 1440px+ wide → included */ - /* Vertical tablets (portrait): always included at 768px+ width */ - .sticky-tablet { - position: static; +/* ================================ + Everything below is scoped to .dark (Madmin only) + ================================ */ +.dark { + /* CSS variables for dark theme */ + --sidebar-bg: oklch(8% 0.02 260); + --sidebar-hover: oklch(100% 0 0 / 5%); + --sidebar-active: oklch(62% 0.21 260 / 15%); + --glow-blue: oklch(62% 0.21 260 / 40%); + --glow-purple: oklch(50% 0.27 300 / 40%); + --card-bg: oklch(23% 0.03 260 / 40%); + --card-shadow: 0 4px 24px oklch(0% 0 0 / 40%); + --card-border: oklch(100% 0 0 / 5%); + + /* Dark prose for Madmin markdown */ + .prose { + line-height: 1.7; + color: var(--color-dark-200); + + p { + margin-top: 0.75rem; + margin-bottom: 0.75rem; + &:first-child { margin-top: 0; } + &:last-child { margin-bottom: 0; } + } + + h1, h2, h3, h4 { + font-weight: 600; + margin-top: 1.5rem; + margin-bottom: 0.75rem; + color: var(--color-dark-50); + &:first-child { margin-top: 0; } + } + + strong, b { + font-weight: 600; + color: var(--color-dark-100); + } + + ul, ol { padding-left: 1.5rem; margin-top: 0.5rem; margin-bottom: 0.5rem; } + ul { list-style-type: disc; } + ol { list-style-type: decimal; } + li { margin-top: 0.25rem; margin-bottom: 0.25rem; } + + blockquote { + border-left: 3px solid var(--color-dark-600); + padding-left: 1rem; + color: var(--color-dark-400); + font-style: italic; + margin: 1rem 0; + } + + a { + color: oklch(70% 0.03 250); + text-decoration: underline; + &:hover { color: oklch(91% 0.02 250); } + } + + table { width: 100%; border-collapse: collapse; margin: 1rem 0; } + th, td { border: 1px solid var(--color-dark-700); padding: 0.5rem; text-align: left; } + th { background-color: var(--color-dark-700); font-weight: 600; color: var(--color-dark-100); } + td { background-color: var(--color-dark-800); } + + code { + background-color: var(--color-dark-700); + color: oklch(75% 0.18 350); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.875em; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + } + + pre { + background-color: var(--color-dark-800); + border-radius: 0.75rem; + padding: 1rem; + overflow-x: auto; + margin: 1rem 0; + + code { + background-color: transparent; + padding: 0; + color: var(--color-dark-200); + } + } } - @media (min-width: 768px) and (min-height: 600px) and (orientation: portrait) { - .sticky-tablet { - position: sticky; + /* Dark Rouge syntax highlighting */ + .highlight { + background-color: var(--color-dark-700); + border-radius: 0.75rem; + padding: 1rem; + overflow-x: auto; + margin: 0.75rem 0; + + pre { margin: 0; padding: 0; background: transparent; } + .c, .ch, .cd, .cm, .cpf, .c1, .cs { color: oklch(62% 0.14 140) !important; } + .k, .kc, .kd, .kn, .kp, .kr, .kt { color: oklch(68% 0.12 250) !important; } + .s, .sb, .sc, .dl, .sd, .s2, .sh, .sx, .s1, .ss { color: oklch(72% 0.1 50) !important; } + .na, .nb, .nc, .no, .nd, .ni, .ne, .nf, .nl, .nn, .nt, .nv { color: oklch(85% 0.1 220) !important; } + .m, .mb, .mf, .mh, .mi, .mo, .mx { color: oklch(82% 0.08 130) !important; } + .o, .ow { color: oklch(87% 0.01 250) !important; } + .p { color: oklch(87% 0.01 250) !important; } + .gi { color: oklch(75% 0.15 175) !important; } + .gd { color: oklch(62% 0.22 25) !important; } + .gh { color: oklch(68% 0.12 250) !important; font-weight: bold; } + .gu { color: oklch(68% 0.12 250) !important; } + .err { color: oklch(62% 0.22 25) !important; } + } + + /* Dark scrollbar */ + ::-webkit-scrollbar { width: 8px; height: 8px; } + ::-webkit-scrollbar-track { background: var(--color-dark-800); } + ::-webkit-scrollbar-thumb { + background: var(--color-dark-500); + border-radius: 4px; + &:hover { background: var(--color-dark-400); } + } + + /* Dark form inputs */ + input[type="text"], + input[type="email"], + input[type="password"], + input[type="number"], + input[type="search"], + input[type="url"], + input[type="date"], + input[type="datetime-local"], + textarea, + select { + background-color: var(--color-dark-700); + border-color: var(--color-dark-500); + color: var(--color-dark-100); + + &:focus { + border-color: oklch(62% 0.21 260); + box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%); } } - @media (min-width: 1400px) and (min-height: 600px) { - .sticky-tablet { - position: sticky; + input::placeholder, + textarea::placeholder { + color: var(--color-dark-500); + } + + /* Global text colors for Madmin */ + h1, h2, h3, h4, h5, h6 { + color: var(--color-dark-50); + } + + p:not([class*="text-"]), + span:not([class*="text-"]):not(a span):not(a span span):not(button span), + div:not([class*="text-"]), + label:not([class*="text-"]) { + color: var(--color-dark-200); + } + + a:not([class*="text-"]) { + color: var(--color-dark-100); + text-decoration: none; + } + + /* Madmin Header */ + .header { + background: transparent; + border: none; + margin-bottom: 1.5rem; + + h1 { color: var(--color-dark-50); font-size: 1.875rem; font-weight: 700; } + .actions { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; } + } + + /* Madmin Buttons */ + .btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5625rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 0.5rem; + transition: all 0.15s ease; + border: 1px solid var(--color-dark-500); + background-color: var(--color-dark-700); + color: var(--color-dark-200); + text-decoration: none; + line-height: 1.5; + + &:hover { + background-color: var(--color-dark-600); + border-color: var(--color-dark-400); + color: var(--color-dark-100); + } + + &-primary, &.btn-primary { + background: linear-gradient(135deg, oklch(62% 0.21 260) 0%, oklch(55% 0.22 260) 100%); + border: none; + color: white; + &:hover { background: linear-gradient(135deg, oklch(55% 0.22 260) 0%, oklch(48% 0.22 260) 100%); color: white; } + } + + &-secondary, &.btn-secondary { + background-color: var(--color-dark-700); + border: 1px solid var(--color-dark-500); + color: var(--color-dark-200); + &:hover { background-color: var(--color-dark-600); border-color: var(--color-dark-400); } + } + + &-danger, &.btn-danger { + background-color: oklch(62% 0.24 25 / 15%); + border: 1px solid oklch(62% 0.24 25 / 30%); + color: oklch(70% 0.19 25); + &:hover { background-color: oklch(62% 0.24 25 / 25%); border-color: oklch(62% 0.24 25 / 50%); } } + + &-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; } } - /* Shorter map on landscape tablets so header text below is visible */ - @media (orientation: landscape) and (min-width: 768px) and (max-width: 1399px) { - .community-map-landscape { - height: 400px; + /* Madmin Filters */ + .filters { + background-color: var(--color-dark-700); + border: 1px solid var(--color-dark-600); + border-radius: 0.75rem; + padding: 1rem; + margin-bottom: 1rem; + label { color: var(--color-dark-300); font-size: 0.875rem; } + } + + /* Madmin Scopes */ + .scopes { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; + .btn.active { background-color: var(--color-dark-600); border-color: var(--color-dark-400); color: var(--color-dark-50); } + } + + /* Madmin Tables */ + .table-scroll { + overflow-x: auto; + background-color: var(--color-dark-700); + border: 1px solid var(--color-dark-600); + border-radius: 0.75rem; + } + + table { + width: 100%; + border-collapse: collapse; + + th { + background-color: var(--color-dark-700); + color: var(--color-dark-400); + font-weight: 500; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.875rem 1rem; + text-align: left; + border-bottom: 1px solid var(--color-dark-600); + + a:not([class*="text-"]) { + color: var(--color-dark-400); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.25rem; + &:hover { color: var(--color-dark-200); } + } + } + + td { + padding: 0.875rem 1rem; + border-bottom: 1px solid var(--color-dark-600); + color: var(--color-dark-400); + + a:not([class*="text-"]) { + text-decoration: none; + &:hover { text-decoration: none; } + &:hover .font-medium { text-decoration: underline; } + } + + a.rounded-full { + transition: filter 0.15s ease; + &:hover { filter: brightness(1.3); } + } + } + + tbody tr { + &:hover { background-color: var(--color-dark-600); } + &:last-child td { border-bottom: none; } } } - @keyframes highlight-blink { - 0% { - background-color: theme(colors.red.200); - color: theme(colors.gray.900); + /* Madmin Pagination */ + .pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 0; + flex-wrap: wrap; + gap: 1rem; + + nav { display: flex; gap: 0.25rem; } + + a, span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 2rem; + padding: 0 0.5rem; + font-size: 0.875rem; + border-radius: 0.375rem; + color: var(--color-dark-300); + text-decoration: none; + transition: all 0.15s ease; } - 50% { - background-color: theme(colors.red.200); - color: theme(colors.gray.900); + + a:hover { background-color: var(--color-dark-700); color: var(--color-dark-100); } + + a[disabled], span.current { + background-color: var(--color-dark-700); + color: var(--color-dark-50); + pointer-events: none; } - 100% { - background-color: theme(colors.gray.50); - color: inherit; + + .pagy-info, .info { color: var(--color-dark-400); font-size: 0.875rem; } + } + + /* Madmin Search */ + .search { + display: flex; + align-items: center; + + input[type="search"] { + background-color: var(--color-dark-700); + border: 1px solid var(--color-dark-500); + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; + color: var(--color-dark-100); + font-size: 0.875rem; + + &:focus { + outline: none; + border-color: oklch(62% 0.21 260); + box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%); + } + + &::placeholder { color: var(--color-dark-500); } } } - - .comment-highlight:target .comment-content, - .comment-highlighting { - animation: highlight-blink 1s ease-in-out; + + /* Madmin Forms */ + .form-input, + .form-select, + input.form-input, + select.form-select { + background-color: var(--color-dark-700); + border: 1px solid var(--color-dark-500); + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; + color: var(--color-dark-100); + font-size: 0.875rem; + line-height: 1.5; + + &:focus { + outline: none; + border-color: oklch(62% 0.21 260); + box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%); + } } - - /* Override Tailwind Typography's default backtick decorations for inline code */ - /* Use utilities layer to ensure it comes after typography plugin styles */ - .prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before, - .prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after, - .prose-sm :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before, - .prose-sm :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after, - .prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before, - .prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after, - .md\:prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before, - .md\:prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after { - content: none !important; + + .form-field { + margin-bottom: 0; + + input[type="text"], + input[type="email"], + input[type="password"], + input[type="number"], + input[type="url"], + input[type="date"], + input[type="datetime-local"], + input[type="time"], + textarea, + select { + width: 100%; + background-color: var(--color-dark-800); + border: 1px solid var(--color-dark-600); + border-radius: 0.5rem; + padding: 0.625rem 0.875rem; + color: var(--color-dark-100); + font-size: 0.9375rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + + &:hover { border-color: var(--color-dark-500); } + &:focus { + outline: none; + border-color: oklch(62% 0.21 260); + box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%); + } + &::placeholder { color: var(--color-dark-500); } + } + + textarea { min-height: 100px; resize: vertical; } + + select { + appearance: none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23737d8c' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + padding-right: 2.5rem; + + &[multiple] { background-image: none; padding-right: 0.875rem; min-height: 120px; } + option { padding: 0.5rem; background-color: var(--color-dark-700); } + } + + input[type="checkbox"], + input[type="radio"] { + width: auto; + background-color: var(--color-dark-700); + border-color: var(--color-dark-500); + &:checked { background-color: oklch(62% 0.21 260); border-color: oklch(62% 0.21 260); } + } + } + + .form-group { + margin-bottom: 1.5rem; + + label { display: block; color: var(--color-dark-300); font-size: 0.875rem; font-weight: 500; margin-bottom: 0.5rem; } + .required { color: oklch(70% 0.19 25); margin-left: 0.25rem; } + .form-description { color: var(--color-dark-500); font-size: 0.8125rem; margin-top: 0.5rem; } + + input, textarea, select { + width: 100%; + background-color: var(--color-dark-800); + border: 1px solid var(--color-dark-600); + border-radius: 0.5rem; + padding: 0.625rem 0.875rem; + color: var(--color-dark-100); + font-size: 0.9375rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + + &:focus { + outline: none; + border-color: oklch(62% 0.21 260); + box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%); + } + } + } + + /* Madmin Show Page */ + .show-page { + background-color: var(--color-dark-700); + border: 1px solid var(--color-dark-600); + border-radius: 0.75rem; + overflow: hidden; + } + + .member-actions { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } + + .field-wrapper { + margin-bottom: 1.5rem; + + label { display: block; color: var(--color-dark-300); font-size: 0.875rem; font-weight: 500; margin-bottom: 0.5rem; } + + input, textarea, select { + width: 100%; + background-color: var(--color-dark-700); + border: 1px solid var(--color-dark-500); + border-radius: 0.5rem; + padding: 0.625rem 0.875rem; + color: var(--color-dark-100); + font-size: 0.9375rem; + + &:focus { + outline: none; + border-color: oklch(62% 0.21 260); + box-shadow: 0 0 0 3px oklch(62% 0.21 260 / 20%); + } + } + } + + /* Madmin Flash messages */ + .flash { + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + + &.notice, &.success { + background-color: oklch(72% 0.19 145 / 15%); + border: 1px solid oklch(72% 0.19 145 / 30%); + color: oklch(78% 0.17 145); + } + + &.alert, &.error { + background-color: oklch(62% 0.24 25 / 15%); + border: 1px solid oklch(62% 0.24 25 / 30%); + color: oklch(70% 0.19 25); + } + } + + /* Madmin pre/code elements */ + pre, code { background-color: var(--color-dark-700); color: var(--color-dark-200); } + pre { padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; } + table pre, table code { background-color: transparent; margin: 0; padding: 0; white-space: pre-wrap; word-break: break-word; } +} + +/* Link and button children should inherit color from parent */ +a[class*="text-"] span, +a[class*="text-"] svg, +button[class*="text-"] span, +button[class*="text-"] svg { + color: inherit; +} + +/* Chat textarea - fully transparent (Madmin) */ +#message_content, +.chat-input textarea { + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + + &:focus { + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + outline: none !important; + } +} + +/* ================================ + Collapsible Sidebar (Madmin) + ================================ */ +@media (min-width: 1024px) { + [data-sidebar-target="mainContent"] { + padding-left: 16rem; + transition: padding-left 0.3s ease-in-out; + + &.sidebar-collapsed { padding-left: 4rem; } + } +} + +aside.sidebar-collapsed { + .sidebar-nested-link { display: none; } + + nav a, + nav > div > div > a, + .p-3 a, + .p-3 button, + .p-3 form button { + justify-content: center; + padding-left: 0; + padding-right: 0; + gap: 0; + } + + .sidebar-group-header > a, + .sidebar-group-header > div { + justify-content: center; + padding-left: 0; + padding-right: 0; + gap: 0; + } + + .sidebar-chevron { display: none; } + + .sidebar-group-header > a > span, + .sidebar-group-header > div > span { gap: 0; } + + .p-3 > .flex.items-center.gap-3.mb-3 { display: none; } + + .sidebar-header { + justify-content: center; + & > a { display: none; } } } diff --git a/app/avo/actions/bulk_delete.rb b/app/avo/actions/bulk_delete.rb deleted file mode 100644 index 325e73a..0000000 --- a/app/avo/actions/bulk_delete.rb +++ /dev/null @@ -1,47 +0,0 @@ -class Avo::Actions::BulkDelete < Avo::BaseAction - self.name = "Bulk Delete" - self.visible = -> { true } - self.message = -> do - count = case query - when ActiveRecord::Relation - query.count - when Array - query.size - else - # Single record - 1 - end - "Are you sure you want to delete #{pluralize(count, 'record')}? This action cannot be undone." - end - self.confirm_button_label = "Delete" - self.cancel_button_label = "Cancel" - - def handle(query:, fields:, current_user:, resource:, **args) - # Ensure query is always a collection - records = case query - when ActiveRecord::Relation - query - when Array - query # Already a collection from our patch - else - [ query ] # Single record, wrap in array - end - count = records.is_a?(Array) ? records.size : records.count - - # Get the model name from the first record if available - model_name = if records.any? - records.first.class.name.underscore.humanize.downcase - else - "record" - end - - begin - # Delete each record individually to respect callbacks and associations - records.each(&:destroy!) - - succeed "Successfully deleted #{count} #{model_name.pluralize(count)}." - rescue => e - error "Failed to delete records: #{e.message}" - end - end -end diff --git a/app/avo/actions/make_admin.rb b/app/avo/actions/make_admin.rb deleted file mode 100644 index bc793a0..0000000 --- a/app/avo/actions/make_admin.rb +++ /dev/null @@ -1,24 +0,0 @@ -class Avo::Actions::MakeAdmin < Avo::BaseAction - self.name = "Make Admin" - self.visible = -> { true } - self.message = "Are you sure you want to make these users admins?" - - def handle(query:, fields:, current_user:, resource:, **args) - # Ensure query is always a collection - users = case query - when ActiveRecord::Relation - query - when Array - query # Already a collection from our patch - else - [ query ] # Single record, wrap in array - end - - users.each do |user| - user.update!(role: :admin) - end - - count = users.is_a?(Array) ? users.size : users.count - succeed "Successfully made #{count} #{'user'.pluralize(count)} admin." - end -end diff --git a/app/avo/actions/regenerate_success_story_image.rb b/app/avo/actions/regenerate_success_story_image.rb deleted file mode 100644 index 6b63794..0000000 --- a/app/avo/actions/regenerate_success_story_image.rb +++ /dev/null @@ -1,16 +0,0 @@ -class Avo::Actions::RegenerateSuccessStoryImage < Avo::BaseAction - self.name = "Regenerate Success Story Image" - self.visible = -> { - return false unless view == :show - - resource.record.success_story? && resource.record.logo_svg.present? - } - - def handle(query:, fields:, current_user:, resource:, **args) - eligible_posts = query.select { |post| post.success_story? && post.logo_svg.present? } - jobs = eligible_posts.map { |post| GenerateSuccessStoryImageJob.new(post, force: true) } - ActiveJob.perform_all_later(jobs) - - succeed "Image regeneration queued for #{eligible_posts.size} #{"post".pluralize(eligible_posts.size)}" - end -end diff --git a/app/avo/actions/regenerate_summary.rb b/app/avo/actions/regenerate_summary.rb deleted file mode 100644 index 0c2f648..0000000 --- a/app/avo/actions/regenerate_summary.rb +++ /dev/null @@ -1,23 +0,0 @@ -class Avo::Actions::RegenerateSummary < Avo::BaseAction - self.name = "Regenerate AI Summary" - self.visible = -> { true } - self.message = "This will replace the existing summary with a new AI-generated one." - - def handle(query:, fields:, current_user:, resource:, **args) - # Ensure query is always a collection - posts = case query - when ActiveRecord::Relation - query - when Array - query # Already a collection from our patch - else - [ query ] # Single record, wrap in array - end - - jobs = posts.map { |post| GenerateSummaryJob.new(post, force: true) } - ActiveJob.perform_all_later(jobs) - - count = posts.is_a?(Array) ? posts.size : posts.count - succeed "AI summary regeneration queued for #{count} #{'post'.pluralize(count)}." - end -end diff --git a/app/avo/actions/toggle_published.rb b/app/avo/actions/toggle_published.rb deleted file mode 100644 index 257f0e9..0000000 --- a/app/avo/actions/toggle_published.rb +++ /dev/null @@ -1,23 +0,0 @@ -class Avo::Actions::TogglePublished < Avo::BaseAction - self.name = "Toggle Published" - self.visible = -> { true } - - def handle(query:, fields:, current_user:, resource:, **args) - # Ensure query is always a collection - records = case query - when ActiveRecord::Relation - query - when Array - query # Already a collection from our patch - else - [ query ] # Single record, wrap in array - end - - records.each do |record| - record.update!(published: !record.published) - end - - count = records.is_a?(Array) ? records.size : records.count - succeed "Successfully toggled published status for #{count} #{'record'.pluralize(count)}." - end -end diff --git a/app/avo/resources/category.rb b/app/avo/resources/category.rb deleted file mode 100644 index d644b7f..0000000 --- a/app/avo/resources/category.rb +++ /dev/null @@ -1,59 +0,0 @@ -class Avo::Resources::Category < Avo::BaseResource - self.title = :name - self.includes = [] - self.model_class = ::Category - self.default_view_type = :table - self.index_query = -> { query.unscoped } - self.record_selector = -> { record.slug.presence || record.id } - - - - self.search = { - query: -> { ::Category.unscoped.ransack(name_cont: params[:q]).result(distinct: false) } - } - - # Override to find records without default scope and use FriendlyId with history support - def self.find_record(id, **kwargs) - # First try to find by current slug or ID - ::Category.unscoped.friendly.find(id) - rescue ActiveRecord::RecordNotFound - # If not found, try to find by historical slug - slug_record = FriendlyId::Slug.where(sluggable_type: "Category", slug: id).order(id: :desc).take - if slug_record - ::Category.unscoped.find(slug_record.sluggable_id) - else - # Last resort: try to find by ID directly (in case it's a ULID) - ::Category.unscoped.find(id) rescue raise ActiveRecord::RecordNotFound.new("Couldn't find Category with 'id'=#{id}") - end - end - - # Handle finding multiple records for bulk actions - def self.find_records(ids, **kwargs) - return [] if ids.blank? - - # Handle both comma-separated string and array - id_list = ids.is_a?(String) ? ids.split(",").map(&:strip) : ids - - # Find each record individually to support slugs - id_list.map { |id| find_record(id, **kwargs) rescue nil }.compact - end - - def fields - field :id, as: :text, readonly: true - field :name, as: :text, required: true - field :description, as: :textarea, rows: 3, placeholder: "Describe what this category is about..." - field :is_success_story, as: :boolean, - help: "This flag cannot be changed once set. Only one category can be marked as the Success Story category.", - readonly: -> { record.persisted? && record.is_success_story? } - field :position, as: :number, required: true - field :created_at, as: :date_time, readonly: true - field :updated_at, as: :date_time, readonly: true - - # Associations - field :posts, as: :has_many - end - - def actions - action Avo::Actions::BulkDelete - end -end diff --git a/app/avo/resources/comment.rb b/app/avo/resources/comment.rb deleted file mode 100644 index 605f1fa..0000000 --- a/app/avo/resources/comment.rb +++ /dev/null @@ -1,45 +0,0 @@ -class Avo::Resources::Comment < Avo::BaseResource - self.title = :body - self.includes = [ :user, :post ] - self.index_query = -> { query.unscoped } - - self.search = { - query: -> { Comment.unscoped.ransack(body_cont: params[:q]).result(distinct: false) } - } - - # Override to find records without default scope - def self.find_record(id, **kwargs) - ::Comment.unscoped.find(id) - end - - def fields - field :id, as: :text, readonly: true, hide_on: [ :index ] - - # Show truncated body on index - field :body, as: :text, - required: true, - only_on: [ :index ], - format_using: -> { value.to_s.truncate(100) if value }, - link_to_record: true - - # Full body for other views - field :body, as: :textarea, required: true, hide_on: [ :index ] - - # Associations - show on index for context - field :user, as: :belongs_to - field :post, as: :belongs_to, - format_using: -> { record.post&.title&.truncate(50) if view == :index } - - # Status - field :published, as: :boolean - - # Timestamps - field :created_at, as: :date_time, readonly: true, hide_on: [ :index ] - field :updated_at, as: :date_time, readonly: true, only_on: [ :index ] - end - - def actions - action Avo::Actions::TogglePublished - action Avo::Actions::BulkDelete - end -end diff --git a/app/avo/resources/post.rb b/app/avo/resources/post.rb deleted file mode 100644 index 1999435..0000000 --- a/app/avo/resources/post.rb +++ /dev/null @@ -1,271 +0,0 @@ -class Avo::Resources::Post < Avo::BaseResource - self.title = :title - self.includes = [ :user, :category, :tags, :comments ] - self.model_class = ::Post - self.index_query = -> { query.unscoped } - self.description = "Manage all posts in the system" - self.default_view_type = :table - self.record_selector = -> { record.slug.presence || record.id } - - self.search = { - query: -> { Post.unscoped.ransack(title_cont: params[:q], content_cont: params[:q], m: "or").result(distinct: false) } - } - - # Override to find records without default scope and use FriendlyId with history support - def self.find_record(id, **kwargs) - # First try to find by current slug or ID - ::Post.unscoped.friendly.find(id) - rescue ActiveRecord::RecordNotFound - # If not found, try to find by historical slug - slug_record = FriendlyId::Slug.find_by(sluggable_type: "Post", slug: id) - if slug_record - ::Post.unscoped.find(slug_record.sluggable_id) - else - raise ActiveRecord::RecordNotFound - end - end - - # Handle finding multiple records for bulk actions - def self.find_records(ids, **kwargs) - return [] if ids.blank? - - # Handle both comma-separated string and array - id_list = ids.is_a?(String) ? ids.split(",").map(&:strip) : ids - - # Find each record individually to support slugs - id_list.map { |id| find_record(id, **kwargs) rescue nil }.compact - end - - def fields - # Compact ID display - only show last 8 chars on index - field :id, as: :text, readonly: true, hide_on: [ :index ] - - # Main content fields - field :title, as: :text, required: true, link_to_record: true - - # User with avatar - custom display for index - field :user, as: :belongs_to, - only_on: [ :forms, :show ] - - field :user_with_avatar, - as: :text, - name: "User", - only_on: [ :index ], - format_using: -> do - if record.user - avatar_url = record.user.avatar_url || "https://avatars.githubusercontent.com/u/0" - link_to view_context.avo.resources_user_path(record.user), - class: "flex items-center gap-2 hover:underline" do - image_tag(avatar_url, class: "w-5 h-5 rounded-full", alt: record.user.username) + - content_tag(:span, record.user.username) - end - else - content_tag(:span, "-", class: "text-gray-400") - end - end - - field :category, as: :belongs_to, - help: "Success stories are auto-assigned to the Success Stories category", - readonly: -> { record.post_type == "success_story" } - - # Post type and success story fields - field :post_type, as: :select, - options: { article: "Article", link: "Link", success_story: "Success Story" }.invert, - hide_on: [ :index ] - - field :logo_svg, as: :textarea, - name: "Logo SVG", - hide_on: [ :index ], - rows: 10, - help: "SVG code for success story logo (only used for Success Story posts)" - - # Status badges for index view - field :published, - as: :text, - name: "Published", - only_on: [ :index ], - format_using: -> do - if record.published - content_tag(:span, class: "inline-flex items-center text-green-600") do - # Simple checkmark for consistency - content_tag(:svg, xmlns: "http://www.w3.org/2000/svg", - class: "w-4 h-4", - viewBox: "0 0 20 20", - fill: "currentColor") do - content_tag(:path, nil, - "fill-rule": "evenodd", - d: "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - "clip-rule": "evenodd") - end - end - else - content_tag(:span, "-", class: "text-gray-400") - end - end - - field :published, as: :boolean, hide_on: [ :index ] - field :pin_position, as: :number, hide_on: [ :index ] - - # Needs Review with custom icons - field :needs_admin_review, - as: :text, - name: "Needs Review", - only_on: [ :index ], - format_using: -> do - if record.needs_admin_review - content_tag(:span, class: "inline-flex items-center text-red-600") do - # Exclamation mark in circle for "yes" - content_tag(:svg, xmlns: "http://www.w3.org/2000/svg", - class: "w-4 h-4", - viewBox: "0 0 20 20", - fill: "currentColor") do - content_tag(:path, nil, - "fill-rule": "evenodd", - d: "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z", - "clip-rule": "evenodd") - end - end - else - content_tag(:span, class: "inline-flex items-center text-green-600") do - # Simple checkmark for consistency with Published field - content_tag(:svg, xmlns: "http://www.w3.org/2000/svg", - class: "w-4 h-4", - viewBox: "0 0 20 20", - fill: "currentColor") do - content_tag(:path, nil, - "fill-rule": "evenodd", - d: "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - "clip-rule": "evenodd") - end - end - end - end - - field :needs_admin_review, as: :boolean, hide_on: [ :index ] - - # URL field for forms - plain text input - field :url, as: :text, - only_on: [ :forms ], - placeholder: "https://example.com/article" - - # URL field for show view - formatted as link - field :url, as: :text, - only_on: [ :show ], - format_using: -> { link_to(value, value, target: "_blank", class: "text-blue-600 hover:underline") if value.present? } - - # Add a compact URL field just for index - field :url, as: :text, - name: "Link", - only_on: [ :index ], - format_using: -> do - if value.present? - domain = URI.parse(value).host rescue "External" - link_to(domain || "Link", value, target: "_blank", class: "text-blue-600 hover:underline") - else - content_tag(:span, "-", class: "text-gray-400") - end - end - - # Long content fields - hide from index - field :content, as: :textarea, hide_on: [ :index ], rows: 20 - field :summary, as: :textarea, hide_on: [ :index ], rows: 5 - field :title_image_url, as: :text, hide_on: [ :index ] - - # Featured image upload without auto-preview/variants (Avo should not generate variants) - field :featured_image, as: :file, - name: "Featured Image", - hide_on: [ :index ], - accept: "image/*", - is_image: false, - help: "Generated automatically for success stories with logos" - - # Read-only preview that uses our pre-generated WebP blobs (no ActiveStorage variants) - field :processed_image_preview, - as: :text, - name: "Image Preview", - only_on: [ :show ], - format_using: -> do - if record.featured_image.attached? - ApplicationController.helpers.post_image_tag(record, size: :post, css_class: "max-w-md border border-gray-300 rounded shadow-sm") - else - content_tag(:span, "No image", class: "text-gray-400") - end - end - - # OG Image Preview - field :og_image_preview, - as: :text, - name: "OG Image Preview", - only_on: [ :show ], - format_using: -> do - # Generate the OG image URL - og_url = if record.featured_image.attached? - "#{view_context.request.base_url}/#{record.category.to_param}/#{record.to_param}/og-image.png?v=#{record.updated_at.to_i}" - else - # Default OG image with version based on file modification time - og_image_path = Rails.root.join("public", "og-image.png") - version = File.exist?(og_image_path) ? File.mtime(og_image_path).to_i.to_s : Time.current.to_i.to_s - "#{view_context.request.base_url}/og-image.png?v=#{version}" - end - - # Build the HTML without concat to avoid duplication - image_link = link_to(og_url, target: "_blank", class: "inline-block") do - image_tag(og_url, - class: "max-w-md border border-gray-300 rounded shadow-sm hover:shadow-md transition-shadow", - alt: "OG Image Preview") - end - - url_display = content_tag(:div, class: "text-sm text-gray-600") do - "URL: #{link_to(og_url, og_url, target: '_blank', class: 'text-blue-600 hover:underline break-all')}".html_safe - end - - content_tag(:div, class: "space-y-2") do - "#{image_link}#{url_display}".html_safe - end - end - - # Counts and metadata - field :reports_count, as: :number, readonly: true, hide_on: [ :index ] - - # Clickable comments count that leads to filtered comments - field :comments, - as: :text, - name: "Comments", - only_on: [ :index ], - format_using: -> do - count = record.comments.count - if count > 0 - link_to count.to_s, - view_context.avo.resources_comments_path(via_record_id: record.id, via_resource_class: "Avo::Resources::Post"), - class: "text-blue-600 hover:underline font-medium", - title: "View comments for this post" - else - content_tag(:span, "0", class: "text-gray-400") - end - end - - # Timestamps - show only updated_at on index - field :created_at, as: :date_time, readonly: true, hide_on: [ :index ] - field :updated_at, as: :date_time, readonly: true - - # Associations - hide from index to save space - field :tags, as: :has_and_belongs_to_many, hide_on: [ :index ] - field :comments, as: :has_many, hide_on: [ :index ] - field :reports, as: :has_many, hide_on: [ :index ] - end - - def actions - action Avo::Actions::TogglePublished - action Avo::Actions::RegenerateSummary - action Avo::Actions::RegenerateSuccessStoryImage - action Avo::Actions::BulkDelete - # action Avo::Actions::PinContent - # action Avo::Actions::ClearReports - end - - # def filters - # filter Avo::Filters::Published - # filter Avo::Filters::NeedsReview - # filter Avo::Filters::ContentType - # end -end diff --git a/app/avo/resources/project.rb b/app/avo/resources/project.rb deleted file mode 100644 index e92d3a4..0000000 --- a/app/avo/resources/project.rb +++ /dev/null @@ -1,76 +0,0 @@ -class Avo::Resources::Project < Avo::BaseResource - self.title = :name - self.includes = [ :user, :star_snapshots ] - self.model_class = ::Project - self.description = "Manage GitHub projects (repositories) for users" - self.default_view_type = :table - - self.search = { - query: -> { Project.ransack(name_cont: params[:q], github_url_cont: params[:q], m: "or").result(distinct: false) } - } - - def fields - field :id, as: :text, readonly: true, hide_on: [ :index ] - - field :name, as: :text, link_to_record: true - - field :user, as: :belongs_to, - only_on: [ :forms, :show ] - - field :user_with_avatar, - as: :text, - name: "User", - only_on: [ :index ], - format_using: -> do - if record.user - avatar_url = record.user.avatar_url || "https://avatars.githubusercontent.com/u/0" - link_to view_context.avo.resources_user_path(record.user), - class: "flex items-center gap-2 hover:underline" do - image_tag(avatar_url, class: "w-5 h-5 rounded-full", alt: record.user.username) + - content_tag(:span, record.user.username) - end - else - content_tag(:span, "-", class: "text-gray-400") - end - end - - field :stars, as: :number, only_on: [ :forms, :show ] - field :stars_with_trend, - as: :text, - name: "Stars", - only_on: [ :index ], - sortable: -> { query.order(stars: direction) }, - format_using: -> do - gained = record.stars_gained - trend = gained > 0 ? content_tag(:span, " +#{gained}", class: "text-green-600") : "" - safe_join([ record.stars.to_s, trend ]) - end - field :github_url, as: :text, only_on: [ :forms ] - field :github_url, as: :text, name: "GitHub", only_on: [ :index ], - format_using: -> do - if value.present? - repo_name = value.split("/").last(2).join("/") - link_to(repo_name, value, target: "_blank", class: "text-blue-600 hover:underline") - else - content_tag(:span, "-", class: "text-gray-400") - end - end - field :github_url, as: :text, only_on: [ :show ], - format_using: -> { link_to(value, value, target: "_blank", class: "text-blue-600 hover:underline") if value.present? } - - field :description, as: :textarea, hide_on: [ :index ], rows: 3 - field :forks_count, as: :number, hide_on: [ :index ] - field :size, as: :number, hide_on: [ :index ] - field :topics, as: :text, hide_on: [ :index ], - format_using: -> { value.is_a?(Array) ? value.join(", ") : value.to_s } - - field :hidden, as: :boolean - field :archived, as: :boolean - - field :pushed_at, as: :date_time, readonly: true, hide_on: [ :index ] - field :created_at, as: :date_time, readonly: true, hide_on: [ :index ] - field :updated_at, as: :date_time, readonly: true - - field :star_snapshots, as: :has_many, hide_on: [ :index ] - end -end diff --git a/app/avo/resources/report.rb b/app/avo/resources/report.rb deleted file mode 100644 index a7f18fe..0000000 --- a/app/avo/resources/report.rb +++ /dev/null @@ -1,21 +0,0 @@ -class Avo::Resources::Report < Avo::BaseResource - self.title = :reason - self.includes = [ :user, :post ] - - # Reports don't have archived field, so no need for unscoped - - def fields - field :id, as: :text, readonly: true - field :reason, as: :select, enum: ::Report.reasons - field :description, as: :textarea - field :created_at, as: :date_time, readonly: true - - # Associations - field :user, as: :belongs_to - field :post, as: :belongs_to - end - - def actions - action Avo::Actions::BulkDelete - end -end diff --git a/app/avo/resources/star_snapshot.rb b/app/avo/resources/star_snapshot.rb deleted file mode 100644 index bbf875f..0000000 --- a/app/avo/resources/star_snapshot.rb +++ /dev/null @@ -1,15 +0,0 @@ -class Avo::Resources::StarSnapshot < Avo::BaseResource - self.title = :recorded_on - self.includes = [ :project ] - self.model_class = ::StarSnapshot - self.description = "Daily star count snapshots for projects" - self.default_view_type = :table - - def fields - field :id, as: :text, readonly: true, hide_on: [ :index ] - field :project, as: :belongs_to - field :stars, as: :number - field :recorded_on, as: :date - field :created_at, as: :date_time, readonly: true, hide_on: [ :index ] - end -end diff --git a/app/avo/resources/tag.rb b/app/avo/resources/tag.rb deleted file mode 100644 index 564436f..0000000 --- a/app/avo/resources/tag.rb +++ /dev/null @@ -1,49 +0,0 @@ -class Avo::Resources::Tag < Avo::BaseResource - self.title = :name - self.includes = [] - self.index_query = -> { query.unscoped } - self.record_selector = -> { record.slug.presence || record.id } - - self.search = { - query: -> { Tag.unscoped.ransack(name_cont: params[:q]).result(distinct: false) } - } - - # Override to find records without default scope and use FriendlyId with history support - def self.find_record(id, **kwargs) - # First try to find by current slug or ID - ::Tag.unscoped.friendly.find(id) - rescue ActiveRecord::RecordNotFound - # If not found, try to find by historical slug - slug_record = FriendlyId::Slug.find_by(sluggable_type: "Tag", slug: id) - if slug_record - ::Tag.unscoped.find(slug_record.sluggable_id) - else - raise ActiveRecord::RecordNotFound - end - end - - # Handle finding multiple records for bulk actions - def self.find_records(ids, **kwargs) - return [] if ids.blank? - - # Handle both comma-separated string and array - id_list = ids.is_a?(String) ? ids.split(",").map(&:strip) : ids - - # Find each record individually to support slugs - id_list.map { |id| find_record(id, **kwargs) rescue nil }.compact - end - - def fields - field :id, as: :text, readonly: true - field :name, as: :text, required: true - field :created_at, as: :date_time, readonly: true - field :updated_at, as: :date_time, readonly: true - - # Associations - field :posts, as: :has_and_belongs_to_many - end - - def actions - action Avo::Actions::BulkDelete - end -end diff --git a/app/avo/resources/testimonial.rb b/app/avo/resources/testimonial.rb deleted file mode 100644 index 522f336..0000000 --- a/app/avo/resources/testimonial.rb +++ /dev/null @@ -1,65 +0,0 @@ -class Avo::Resources::Testimonial < Avo::BaseResource - self.title = :heading - self.includes = [ :user ] - self.model_class = ::Testimonial - self.description = "Manage user testimonials" - self.default_view_type = :table - - def fields - field :id, as: :text, readonly: true, hide_on: [ :index ] - - field :user_with_avatar, - as: :text, - name: "User", - only_on: [ :index ], - format_using: -> do - if record.user - avatar_url = record.user.avatar_url || "https://avatars.githubusercontent.com/u/0" - link_to view_context.avo.resources_user_path(record.user), - class: "flex items-center gap-2 hover:underline" do - image_tag(avatar_url, class: "w-5 h-5 rounded-full", alt: record.user.username) + - content_tag(:span, record.user.username) - end - else - content_tag(:span, "-", class: "text-gray-400") - end - end - - field :user, as: :belongs_to, only_on: [ :forms, :show ] - - field :heading, as: :text, link_to_record: true - field :subheading, as: :text, hide_on: [ :index ] - field :quote, as: :textarea, rows: 4 - field :body_text, as: :textarea, rows: 6, hide_on: [ :index ] - - field :published, - as: :text, - name: "Published", - only_on: [ :index ], - format_using: -> do - if record.published - content_tag(:span, class: "inline-flex items-center text-green-600") do - content_tag(:svg, xmlns: "http://www.w3.org/2000/svg", - class: "w-4 h-4", - viewBox: "0 0 20 20", - fill: "currentColor") do - content_tag(:path, nil, - "fill-rule": "evenodd", - d: "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - "clip-rule": "evenodd") - end - end - else - content_tag(:span, "-", class: "text-gray-400") - end - end - - field :published, as: :boolean, hide_on: [ :index ] - field :position, as: :number - field :ai_feedback, as: :textarea, rows: 3, hide_on: [ :index ] - field :ai_attempts, as: :number, readonly: true, hide_on: [ :index ] - - field :created_at, as: :date_time, readonly: true, hide_on: [ :index ] - field :updated_at, as: :date_time, readonly: true - end -end diff --git a/app/avo/resources/user.rb b/app/avo/resources/user.rb deleted file mode 100644 index 9e86f86..0000000 --- a/app/avo/resources/user.rb +++ /dev/null @@ -1,74 +0,0 @@ -class Avo::Resources::User < Avo::BaseResource - self.title = :username - self.includes = [ :posts, :comments ] - self.index_query = -> { query.unscoped } - self.description = "Manage system users" - self.record_selector = -> { record.slug.presence || record.id } - - self.search = { - query: -> { User.unscoped.ransack(username_cont: params[:q], email_cont: params[:q], m: "or").result(distinct: false) } - } - - # Override to find records without default scope and use FriendlyId with history support - def self.find_record(id, **kwargs) - # First try to find by current slug or ID - ::User.unscoped.friendly.find(id) - rescue ActiveRecord::RecordNotFound - # If not found, try to find by historical slug - slug_record = FriendlyId::Slug.find_by(sluggable_type: "User", slug: id) - if slug_record - ::User.unscoped.find(slug_record.sluggable_id) - else - raise ActiveRecord::RecordNotFound - end - end - - # Handle finding multiple records for bulk actions - def self.find_records(ids, **kwargs) - return [] if ids.blank? - - # Handle both comma-separated string and array - id_list = ids.is_a?(String) ? ids.split(",").map(&:strip) : ids - - # Find each record individually to support slugs - id_list.map { |id| find_record(id, **kwargs) rescue nil }.compact - end - - def fields - # Compact display for index - field :id, as: :text, readonly: true, hide_on: [ :index ] - field :avatar_url, as: :external_image, link_to_record: true, circular: true, size: :sm - field :username, as: :text, readonly: true, link_to_record: true - field :email, as: :text, readonly: true, hide_on: [ :index ] - field :role, as: :select, enum: ::User.roles - - # Activity indicators for index - field :published_posts_count, as: :number, readonly: true, name: "Posts" - field :published_comments_count, as: :number, readonly: true, name: "Comments", hide_on: [ :forms, :show ] - - # Status - field :trusted?, as: :boolean, readonly: true, name: "Trusted" - - # GitHub info - hide from index - field :github_id, as: :number, readonly: true, hide_on: [ :index ] - - # Timestamps - only show created_at on index - field :created_at, as: :date_time, readonly: true - field :updated_at, as: :date_time, readonly: true, hide_on: [ :index ] - - # Newsletter - field :unsubscribed_from_newsletter, as: :boolean, readonly: true, hide_on: [ :index ] - field :newsletters_received, as: :text, readonly: true, hide_on: [ :index ], format_using: -> { value.is_a?(Array) ? value.map { |v| NewsletterMailer::SUBJECTS[v] || "v#{v}" }.join(", ") : value.to_s } - field :newsletters_opened, as: :text, readonly: true, hide_on: [ :index ], format_using: -> { value.is_a?(Array) ? value.map { |v| NewsletterMailer::SUBJECTS[v] || "v#{v}" }.join(", ") : value.to_s } - - # Associations - hide from index - field :posts, as: :has_many, hide_on: [ :index ] - field :comments, as: :has_many, hide_on: [ :index ] - field :reports, as: :has_many, hide_on: [ :index ] - end - - def actions - action Avo::Actions::MakeAdmin - action Avo::Actions::BulkDelete - end -end diff --git a/app/controllers/admins/sessions/verifications_controller.rb b/app/controllers/admins/sessions/verifications_controller.rb new file mode 100644 index 0000000..1c49b65 --- /dev/null +++ b/app/controllers/admins/sessions/verifications_controller.rb @@ -0,0 +1,15 @@ +class Admins::Sessions::VerificationsController < ApplicationController + rate_limit to: 5, within: 5.minutes, name: "admin_sessions/verify", + with: -> { redirect_to new_admins_session_path, alert: t("controllers.admins.sessions.rate_limit.verify") } + + layout "admin_auth" + + def show + admin = Admin.find_signed!(params[:token], purpose: :magic_link) + session[:admin_id] = admin.id + + redirect_to "/madmin", notice: t("controllers.admins.sessions.verify.notice") + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_admins_session_path, alert: t("controllers.admins.sessions.verify.alert") + end +end diff --git a/app/controllers/admins/sessions_controller.rb b/app/controllers/admins/sessions_controller.rb new file mode 100644 index 0000000..f9c127b --- /dev/null +++ b/app/controllers/admins/sessions_controller.rb @@ -0,0 +1,33 @@ +class Admins::SessionsController < ApplicationController + # Admin login and magic link verification + # All admin management happens through Madmin at /madmin + + # Stricter limits for admin authentication + rate_limit to: 3, within: 1.minute, name: "admin_sessions/short", only: :create, + with: -> { redirect_to new_admins_session_path, alert: t("controllers.admins.sessions.rate_limit.short") } + rate_limit to: 10, within: 1.hour, name: "admin_sessions/long", only: :create, + with: -> { redirect_to new_admins_session_path, alert: t("controllers.admins.sessions.rate_limit.long") } + layout "admin_auth", only: [ :new, :create ] + + def new + # Show admin login form + end + + def create + email = params.expect(session: :email)[:email] + admin = Admin.find_by(email: email) + + if admin + # Send magic link to existing admin + AdminMailer.magic_link(admin).deliver_later + redirect_to new_admins_session_path, notice: t("controllers.admins.sessions.create.notice") + else + redirect_to new_admins_session_path, alert: t("controllers.admins.sessions.create.alert") + end + end + + def destroy + reset_session + redirect_to root_path, notice: t("controllers.admins.sessions.destroy.notice") + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 931132e..bc12150 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,19 +1,138 @@ class ApplicationController < ActionController::Base + rate_limit to: 100, within: 1.minute, name: "global" + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern - protected + before_action :set_current_user + before_action :set_locale + before_action :set_current_team, if: :team_scoped_request? + + private + + # ── Session-based authentication ── + + def current_user + @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id] + end + helper_method :current_user + + def user_signed_in? + current_user.present? + end + helper_method :user_signed_in? + + def sign_in(user) + reset_session + session[:user_id] = user.id + @current_user = user + end + + def sign_out(_user = nil) + reset_session + @current_user = nil + end + + def authenticate_user! + unless user_signed_in? + session[:return_to] = request.original_url if request.get? + redirect_to github_auth_with_return_path, alert: "Please sign in with GitHub to continue." + end + end + + # ── Current attributes ── + + def set_current_user + Current.user = current_user if defined?(Current) && Current.respond_to?(:user=) + end + + # ── Locale ── + + def set_locale + I18n.locale = detect_locale + end + + def detect_locale + if current_user&.respond_to?(:locale) && current_user.locale.present? + return current_user.locale.to_sym + end + + if request.headers["Accept-Language"] && defined?(Language) + accepted = parse_accept_language(request.headers["Accept-Language"]) + enabled = Language.enabled_codes + + accepted.each do |code| + return code.to_sym if enabled.include?(code) + end + end + + I18n.default_locale + end + + def detected_locale + I18n.locale + end + helper_method :detected_locale - def after_sign_in_path_for(resource) - # Check if there's a stored return path - return_to = session.delete(:return_to) - return return_to if return_to.present? + # ── Team scoping ── - # Otherwise redirect to user's profile after sign in - if resource.is_a?(User) - user_path(resource) - else - super + def set_current_team + return unless current_user + + @current_team = current_user.teams.find_by(slug: params[:team_slug]) + + unless @current_team + redirect_to teams_path, alert: t("controllers.application.team_not_found", default: "Team not found") + return end + + Current.team = @current_team if defined?(Current) && Current.respond_to?(:team=) + Current.membership = current_user.membership_for(@current_team) if defined?(Current) && Current.respond_to?(:membership=) + end + + def team_scoped_request? + params[:team_slug].present? + end + + def current_team + @current_team + end + helper_method :current_team + + def current_membership + current_user&.membership_for(@current_team) if @current_team + end + helper_method :current_membership + + def current_admin + @current_admin ||= Admin.find_by(id: session[:admin_id]) if session[:admin_id] + end + helper_method :current_admin + + def authenticate_admin! + redirect_to root_path, alert: "Admin access required" unless current_user&.admin? + end + + def require_team_admin! + unless current_membership&.admin? + redirect_to team_root_path(current_team), alert: t("controllers.application.admin_required", default: "Admin access required") + end + end + + def require_subscription! + return unless current_team + return if current_team.subscription_active? + + redirect_to team_pricing_path(current_team), + alert: t("controllers.application.subscription_required", default: "Subscription required") + end + + def parse_accept_language(header) + header.to_s.split(",").filter_map { |entry| + lang, quality = entry.strip.split(";") + code = lang&.strip&.split("-")&.first&.downcase + q = quality ? quality.strip.delete_prefix("q=").to_f : 1.0 + [ code, q ] if code.present? + }.sort_by { |_, q| -q }.map(&:first).uniq end end diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb new file mode 100644 index 0000000..0284c47 --- /dev/null +++ b/app/controllers/articles_controller.rb @@ -0,0 +1,52 @@ +class ArticlesController < ApplicationController + before_action :authenticate_user! + before_action :set_article, only: [ :show, :edit, :update, :destroy ] + + def index + @articles = current_team.articles.includes(:user).recent + end + + def show + end + + def new + @article = current_team.articles.new + end + + def create + @article = current_team.articles.new(article_params) + @article.user = current_user + + if @article.save + redirect_to team_article_path(current_team, @article), notice: t("controllers.articles.create.notice") + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @article.update(article_params) + redirect_to team_article_path(current_team, @article), notice: t("controllers.articles.update.notice") + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @article.destroy! + redirect_to team_articles_path(current_team), notice: t("controllers.articles.destroy.notice") + end + + private + + def set_article + @article = current_team.articles.find(params[:id]) + end + + def article_params + params.require(:article).permit(:title, :body) + end +end diff --git a/app/controllers/avo/categories_controller.rb b/app/controllers/avo/categories_controller.rb deleted file mode 100644 index debee0f..0000000 --- a/app/controllers/avo/categories_controller.rb +++ /dev/null @@ -1,24 +0,0 @@ -class Avo::CategoriesController < Avo::ResourcesController - # Override to handle slug changes properly - def update - super - rescue ActiveRecord::RecordNotFound - # If the record wasn't found, redirect to index - redirect_to avo.resources_categories_path, alert: "Category not found" - end - - private - - # Override the redirect path after update to use the new slug - def after_update_path - return params[:referrer] if params[:referrer].present? - - # Use the updated record's current slug for the redirect - # @record should be the updated category at this point - if @record - avo.resources_category_path(id: @record.slug || @record.id) - else - avo.resources_categories_path - end - end -end diff --git a/app/controllers/avo/comments_controller.rb b/app/controllers/avo/comments_controller.rb deleted file mode 100644 index ec054a5..0000000 --- a/app/controllers/avo/comments_controller.rb +++ /dev/null @@ -1,2 +0,0 @@ -class Avo::CommentsController < Avo::ResourcesController -end diff --git a/app/controllers/avo/posts_controller.rb b/app/controllers/avo/posts_controller.rb deleted file mode 100644 index b428701..0000000 --- a/app/controllers/avo/posts_controller.rb +++ /dev/null @@ -1,23 +0,0 @@ -class Avo::PostsController < Avo::ResourcesController - # Override to handle slug changes properly - def update - super - rescue ActiveRecord::RecordNotFound - # If the record wasn't found, redirect to index - redirect_to avo.resources_posts_path, alert: "Post not found" - end - - private - - # Override the redirect path after update to use the new slug - def after_update_path - return params[:referrer] if params[:referrer].present? - - # Use the updated record's current slug for the redirect - if @record - avo.resources_post_path(id: @record.slug || @record.id) - else - avo.resources_posts_path - end - end -end diff --git a/app/controllers/avo/projects_controller.rb b/app/controllers/avo/projects_controller.rb deleted file mode 100644 index cebb186..0000000 --- a/app/controllers/avo/projects_controller.rb +++ /dev/null @@ -1,2 +0,0 @@ -class Avo::ProjectsController < Avo::ResourcesController -end diff --git a/app/controllers/avo/reports_controller.rb b/app/controllers/avo/reports_controller.rb deleted file mode 100644 index c0c1cb6..0000000 --- a/app/controllers/avo/reports_controller.rb +++ /dev/null @@ -1,2 +0,0 @@ -class Avo::ReportsController < Avo::ResourcesController -end diff --git a/app/controllers/avo/star_snapshots_controller.rb b/app/controllers/avo/star_snapshots_controller.rb deleted file mode 100644 index b296f6a..0000000 --- a/app/controllers/avo/star_snapshots_controller.rb +++ /dev/null @@ -1,2 +0,0 @@ -class Avo::StarSnapshotsController < Avo::ResourcesController -end diff --git a/app/controllers/avo/tags_controller.rb b/app/controllers/avo/tags_controller.rb deleted file mode 100644 index 7ed1ec3..0000000 --- a/app/controllers/avo/tags_controller.rb +++ /dev/null @@ -1,23 +0,0 @@ -class Avo::TagsController < Avo::ResourcesController - # Override to handle slug changes properly - def update - super - rescue ActiveRecord::RecordNotFound - # If the record wasn't found, redirect to index - redirect_to avo.resources_tags_path, alert: "Tag not found" - end - - private - - # Override the redirect path after update to use the new slug - def after_update_path - return params[:referrer] if params[:referrer].present? - - # Use the updated record's current slug for the redirect - if @record - avo.resources_tag_path(id: @record.slug || @record.id) - else - avo.resources_tags_path - end - end -end diff --git a/app/controllers/avo/testimonials_controller.rb b/app/controllers/avo/testimonials_controller.rb deleted file mode 100644 index 6a367a9..0000000 --- a/app/controllers/avo/testimonials_controller.rb +++ /dev/null @@ -1,2 +0,0 @@ -class Avo::TestimonialsController < Avo::ResourcesController -end diff --git a/app/controllers/avo/users_controller.rb b/app/controllers/avo/users_controller.rb deleted file mode 100644 index cc2b2f3..0000000 --- a/app/controllers/avo/users_controller.rb +++ /dev/null @@ -1,23 +0,0 @@ -class Avo::UsersController < Avo::ResourcesController - # Override to handle slug changes properly - def update - super - rescue ActiveRecord::RecordNotFound - # If the record wasn't found, redirect to index - redirect_to avo.resources_users_path, alert: "User not found" - end - - private - - # Override the redirect path after update to use the new slug - def after_update_path - return params[:referrer] if params[:referrer].present? - - # Use the updated record's current slug for the redirect - if @record - avo.resources_user_path(id: @record.slug || @record.id) - else - avo.resources_users_path - end - end -end diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb new file mode 100644 index 0000000..d1aabf0 --- /dev/null +++ b/app/controllers/chats_controller.rb @@ -0,0 +1,51 @@ +class ChatsController < ApplicationController + include Attachable + + before_action :authenticate_user! + before_action :require_chats_enabled!, only: [ :index, :new, :create ] + before_action :set_chat, only: [ :show ] + + # Prevent chat spam + rate_limit to: 10, within: 1.minute, name: "chats/create", only: :create + + def index + @chats = current_user.chats.where(team: current_team).includes(:model, :messages).recent + end + + def new + @chat = current_user.chats.build(team: current_team) + @selected_model = params[:model] + end + + def create + return unless prompt.present? || attachments.present? + + @chat = current_user.chats.create!(model: model, team: current_team) + attachment_paths = store_attachments_temporarily(attachments) + ChatResponseJob.perform_later(@chat.id, prompt, attachment_paths) + + redirect_to team_chat_path(current_team, @chat) + end + + def show + @message = @chat.messages.build + end + + private + + def set_chat + @chat = current_user.chats.where(team: current_team).includes(messages: [ :tool_calls, { attachments_attachments: :blob } ]).find(params[:id]) + end + + def model + params[:chat][:model].presence + end + + def prompt + params[:chat][:prompt] + end + + def attachments + params.dig(:chat, :attachments) + end +end diff --git a/app/controllers/concerns/attachable.rb b/app/controllers/concerns/attachable.rb new file mode 100644 index 0000000..42fa920 --- /dev/null +++ b/app/controllers/concerns/attachable.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Shared attachment handling for controllers that accept file uploads +module Attachable + extend ActiveSupport::Concern + + private + + def store_attachments_temporarily(attachments) + return [] unless attachments.present? + + attachments.reject(&:blank?).map do |attachment| + temp_dir = Rails.root.join("tmp", "uploads", SecureRandom.uuid) + FileUtils.mkdir_p(temp_dir) + safe_filename = File.basename(attachment.original_filename).gsub(/[^\w.\-]/, "_") + temp_path = temp_dir.join(safe_filename) + File.binwrite(temp_path, attachment.read) + temp_path.to_s + end + end +end diff --git a/app/controllers/madmin/active_storage/attachments_controller.rb b/app/controllers/madmin/active_storage/attachments_controller.rb new file mode 100644 index 0000000..d950ae6 --- /dev/null +++ b/app/controllers/madmin/active_storage/attachments_controller.rb @@ -0,0 +1,4 @@ +module Madmin + class ActiveStorage::AttachmentsController < Madmin::ResourceController + end +end diff --git a/app/controllers/madmin/active_storage/blobs_controller.rb b/app/controllers/madmin/active_storage/blobs_controller.rb new file mode 100644 index 0000000..fb59018 --- /dev/null +++ b/app/controllers/madmin/active_storage/blobs_controller.rb @@ -0,0 +1,8 @@ +module Madmin + class ActiveStorage::BlobsController < Madmin::ResourceController + def new + super + @record.assign_attributes(filename: "") + end + end +end diff --git a/app/controllers/madmin/active_storage/variant_records_controller.rb b/app/controllers/madmin/active_storage/variant_records_controller.rb new file mode 100644 index 0000000..8798628 --- /dev/null +++ b/app/controllers/madmin/active_storage/variant_records_controller.rb @@ -0,0 +1,4 @@ +module Madmin + class ActiveStorage::VariantRecordsController < Madmin::ResourceController + end +end diff --git a/app/controllers/madmin/admins_controller.rb b/app/controllers/madmin/admins_controller.rb new file mode 100644 index 0000000..34acf90 --- /dev/null +++ b/app/controllers/madmin/admins_controller.rb @@ -0,0 +1,9 @@ +module Madmin + class AdminsController < Madmin::ResourceController + def send_magic_link + @record = Admin.find(params[:id]) + AdminMailer.magic_link(@record).deliver_later + redirect_to madmin_admin_path(@record), notice: "Magic link sent to #{@record.email}!" + end + end +end diff --git a/app/controllers/madmin/application_controller.rb b/app/controllers/madmin/application_controller.rb new file mode 100644 index 0000000..7e20183 --- /dev/null +++ b/app/controllers/madmin/application_controller.rb @@ -0,0 +1,19 @@ +module Madmin + class ApplicationController < Madmin::BaseController + before_action :authenticate_admin! + helper Madmin::ApplicationHelper + + private + + def authenticate_admin! + admin = Admin.find_by(id: session[:admin_id]) if session[:admin_id] + redirect_to main_app.new_admins_session_path, alert: "Please log in as admin" unless admin + end + + helper_method :current_admin + + def current_admin + @current_admin ||= Admin.find_by(id: session[:admin_id]) if session[:admin_id] + end + end +end diff --git a/app/controllers/madmin/categories_controller.rb b/app/controllers/madmin/categories_controller.rb new file mode 100644 index 0000000..01ce320 --- /dev/null +++ b/app/controllers/madmin/categories_controller.rb @@ -0,0 +1,9 @@ +module Madmin + class CategoriesController < Madmin::ResourceController + private + + def scoped_resources + super.includes(:posts) + end + end +end diff --git a/app/controllers/madmin/chats_controller.rb b/app/controllers/madmin/chats_controller.rb new file mode 100644 index 0000000..5ab2651 --- /dev/null +++ b/app/controllers/madmin/chats_controller.rb @@ -0,0 +1,31 @@ +module Madmin + class ChatsController < Madmin::ResourceController + skip_before_action :set_record, only: :toggle_public_chats + + def toggle_public_chats + setting = Setting.instance + setting.update!(public_chats: !setting.public_chats?) + redirect_to main_app.madmin_chats_path, notice: "Public chats #{setting.public_chats? ? 'enabled' : 'disabled'}" + end + + private + + def scoped_resources + resources = super.includes(:user, :model, :messages) + + if params[:created_at_from].present? && params[:created_at_to].present? + resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to]) + elsif params[:created_at].present? + date = Date.parse(params[:created_at]) + resources = resources.where("DATE(created_at) = ?", date) + end + + # Custom search by user email + if params[:q].present? + resources = resources.joins(:user).where("users.email LIKE ?", "%#{params[:q]}%") + end + + resources + end + end +end diff --git a/app/controllers/madmin/comments_controller.rb b/app/controllers/madmin/comments_controller.rb new file mode 100644 index 0000000..d753817 --- /dev/null +++ b/app/controllers/madmin/comments_controller.rb @@ -0,0 +1,15 @@ +module Madmin + class CommentsController < Madmin::ResourceController + private + + def scoped_resources + resources = super.includes(:user, :post) + + if params[:published].present? + resources = resources.where(published: params[:published] == "true") + end + + resources + end + end +end diff --git a/app/controllers/madmin/dashboard_controller.rb b/app/controllers/madmin/dashboard_controller.rb new file mode 100644 index 0000000..443168d --- /dev/null +++ b/app/controllers/madmin/dashboard_controller.rb @@ -0,0 +1,84 @@ +module Madmin + class DashboardController < Madmin::ApplicationController + def show + @metrics = { + total_users: User.count, + total_admins: Admin.count, + total_teams: Team.count, + total_chats: Chat.count, + total_messages: Message.count, + total_tokens: calculate_total_tokens, + total_cost: Message.sum(:cost), + total_tool_calls: ToolCall.count, + recent_chats: Chat.where("created_at >= ?", 7.days.ago).count, + recent_messages: Message.where("created_at >= ?", 7.days.ago).count, + recent_users: User.where("created_at >= ?", 7.days.ago).count, + recent_teams: Team.where("created_at >= ?", 7.days.ago).count, + total_models: Model.enabled.count, + total_projects: Project.count, + total_testimonials: Testimonial.count, + published_testimonials: Testimonial.published.count + } + + @subscription_stats = { + active: Team.where(subscription_status: "active").count, + trialing: Team.where(subscription_status: "trialing").count, + past_due: Team.where(subscription_status: "past_due").count, + canceled: Team.where(subscription_status: "canceled").count, + none: Team.where(subscription_status: [ nil, "" ]).count + } + + @subscription_revenue = calculate_subscription_revenue + + @recent_chats = Chat.includes(:user, :model, :messages).order(created_at: :desc).limit(5) + @recent_users = User.includes(:memberships).order(created_at: :desc).limit(5) + @recent_teams = Team.includes(:memberships, :chats).order(created_at: :desc).limit(5) + + @activity_chart_data = build_activity_chart_data + end + + private + + def calculate_total_tokens + Message.sum("COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0) + COALESCE(cached_tokens, 0) + COALESCE(cache_creation_tokens, 0)") + end + + def calculate_subscription_revenue + Rails.cache.fetch("admin_subscription_revenue", expires_in: 15.minutes) do + fetch_stripe_revenue + end + end + + def fetch_stripe_revenue + return { mrr: 0, total: 0, available: false } unless Setting.instance.stripe_secret_key.present? + + subs = Stripe::Subscription.list(status: "active", limit: 100) + mrr = subs.data.sum do |s| + s.items.data.sum do |item| + amount = item.price.unit_amount.to_f + item.price.recurring&.interval == "year" ? amount / 12.0 : amount + end + end / 100.0 + + invoices = Stripe::Invoice.list(status: "paid", limit: 100) + total = invoices.data.sum(&:amount_paid) / 100.0 + + { mrr: mrr, total: total, available: true } + rescue => e + Rails.logger.warn("Failed to fetch Stripe revenue: #{e.message}") + { mrr: 0, total: 0, available: false } + end + + def build_activity_chart_data + dates = (6.days.ago.to_date..Date.current).to_a + + cost_sums = Message.where(created_at: dates.first.all_day.first..dates.last.end_of_day) + .group("date(created_at)").sum(:cost) + + { + labels: dates.map { |d| d.strftime("%b %d") }, + cost: dates.map { |d| (cost_sums[d.to_s] || 0).to_f } + } + end + end +end diff --git a/app/controllers/madmin/languages_controller.rb b/app/controllers/madmin/languages_controller.rb new file mode 100644 index 0000000..69a1be7 --- /dev/null +++ b/app/controllers/madmin/languages_controller.rb @@ -0,0 +1,20 @@ +module Madmin + class LanguagesController < Madmin::ResourceController + skip_before_action :set_record, only: :sync + + def sync + result = Language.sync_from_locale_files! + parts = [] + parts << "Added #{result[:added].join(', ')}" if result[:added].any? + parts << "Removed #{result[:removed].join(', ')}" if result[:removed].any? + notice = parts.any? ? parts.join(". ") : "All locale files already synced" + redirect_to main_app.madmin_languages_path, notice: notice + end + + def toggle + language = Language.find(params[:id]) + language.update!(enabled: !language.enabled?) + redirect_to main_app.madmin_languages_path, notice: "#{language.name} #{language.enabled? ? 'enabled' : 'disabled'}" + end + end +end diff --git a/app/controllers/madmin/mail_controller.rb b/app/controllers/madmin/mail_controller.rb new file mode 100644 index 0000000..14a0ab2 --- /dev/null +++ b/app/controllers/madmin/mail_controller.rb @@ -0,0 +1,27 @@ +module Madmin + class MailController < Madmin::ApplicationController + def show + @setting = Setting.instance + end + + def edit + @setting = Setting.instance + end + + def update + @setting = Setting.instance + + if @setting.update(mail_params) + redirect_to main_app.madmin_mail_path, notice: t("controllers.madmin.mail.update.notice") + else + render :edit, status: :unprocessable_entity + end + end + + private + + def mail_params + params.require(:setting).permit(:mail_from, :smtp_address, :smtp_username, :smtp_password) + end + end +end diff --git a/app/controllers/madmin/messages_controller.rb b/app/controllers/madmin/messages_controller.rb new file mode 100644 index 0000000..eb5eb92 --- /dev/null +++ b/app/controllers/madmin/messages_controller.rb @@ -0,0 +1,20 @@ +module Madmin + class MessagesController < Madmin::ResourceController + def scoped_resources + resources = super.includes(:chat, :model, :tool_calls) + + # Role filter + resources = resources.where(role: params[:role]) if params[:role].present? + + # Date filter + if params[:created_at_from].present? && params[:created_at_to].present? + resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to]) + elsif params[:created_at].present? + date = Date.parse(params[:created_at]) + resources = resources.where("DATE(created_at) = ?", date) + end + + resources + end + end +end diff --git a/app/controllers/madmin/models_controller.rb b/app/controllers/madmin/models_controller.rb new file mode 100644 index 0000000..ae41f1e --- /dev/null +++ b/app/controllers/madmin/models_controller.rb @@ -0,0 +1,16 @@ +module Madmin + class ModelsController < Madmin::ResourceController + skip_before_action :set_record, only: [ :refresh_all ] + + def scoped_resources + resources = super.enabled + resources = resources.where(provider: params[:provider]) if params[:provider].present? + resources + end + + def refresh_all + Model.refresh! + redirect_to resource.index_path, notice: "Models refreshed! Total: #{Model.count}" + end + end +end diff --git a/app/controllers/madmin/posts_controller.rb b/app/controllers/madmin/posts_controller.rb new file mode 100644 index 0000000..9e2d798 --- /dev/null +++ b/app/controllers/madmin/posts_controller.rb @@ -0,0 +1,23 @@ +module Madmin + class PostsController < Madmin::ResourceController + private + + def scoped_resources + resources = super.includes(:user, :category).unscope(:order) + + if params[:post_type].present? + resources = resources.where(post_type: params[:post_type]) + end + + if params[:published].present? + resources = resources.where(published: params[:published] == "true") + end + + if params[:needs_review] == "true" + resources = resources.where(needs_admin_review: true) + end + + resources + end + end +end diff --git a/app/controllers/madmin/prices_controller.rb b/app/controllers/madmin/prices_controller.rb new file mode 100644 index 0000000..3e758e9 --- /dev/null +++ b/app/controllers/madmin/prices_controller.rb @@ -0,0 +1,15 @@ +module Madmin + class PricesController < Madmin::ApplicationController + def show + @setting = Setting.instance + @prices = @setting.stripe_secret_key.present? ? Price.all : [] + rescue Stripe::AuthenticationError, Stripe::APIConnectionError + @prices = [] + end + + def sync + Price.clear_cache + redirect_to main_app.madmin_prices_path, notice: t("controllers.madmin.prices.sync.notice") + end + end +end diff --git a/app/controllers/madmin/projects_controller.rb b/app/controllers/madmin/projects_controller.rb new file mode 100644 index 0000000..00a0f1d --- /dev/null +++ b/app/controllers/madmin/projects_controller.rb @@ -0,0 +1,19 @@ +module Madmin + class ProjectsController < Madmin::ResourceController + private + + def scoped_resources + resources = super.includes(:user) + + if params[:hidden] == "true" + resources = resources.where(hidden: true) + end + + if params[:archived] == "true" + resources = resources.where(archived: true) + end + + resources + end + end +end diff --git a/app/controllers/madmin/providers_controller.rb b/app/controllers/madmin/providers_controller.rb new file mode 100644 index 0000000..f34b626 --- /dev/null +++ b/app/controllers/madmin/providers_controller.rb @@ -0,0 +1,18 @@ +module Madmin + class ProvidersController < Madmin::ApplicationController + def index + @providers = ProviderCredential.provider_settings + @credentials = ProviderCredential.all.index_by { |c| [ c.provider, c.key ] } + end + + def update + params[:providers]&.each do |provider, settings| + settings.each do |key, value| + ProviderCredential.set(provider, key, value) + end + end + + redirect_to main_app.madmin_providers_path, notice: t("controllers.madmin.providers.update.notice") + end + end +end diff --git a/app/controllers/madmin/reports_controller.rb b/app/controllers/madmin/reports_controller.rb new file mode 100644 index 0000000..1227205 --- /dev/null +++ b/app/controllers/madmin/reports_controller.rb @@ -0,0 +1,15 @@ +module Madmin + class ReportsController < Madmin::ResourceController + private + + def scoped_resources + resources = super.includes(:user, :post) + + if params[:reason].present? + resources = resources.where(reason: params[:reason]) + end + + resources + end + end +end diff --git a/app/controllers/madmin/settings/ai_models_controller.rb b/app/controllers/madmin/settings/ai_models_controller.rb new file mode 100644 index 0000000..3088641 --- /dev/null +++ b/app/controllers/madmin/settings/ai_models_controller.rb @@ -0,0 +1,35 @@ +module Madmin + module Settings + class AiModelsController < Madmin::ApplicationController + def show + @setting = Setting.instance + end + + def edit + @setting = Setting.instance + end + + def update + @setting = Setting.instance + + if @setting.update(setting_params) + redirect_to main_app.madmin_settings_ai_models_path, notice: t("controllers.madmin.settings.ai_models.update.notice") + else + render :edit, status: :unprocessable_entity + end + end + + private + + def setting_params + params.require(:setting).permit( + :default_ai_model, + :summary_model, + :testimonial_model, + :translation_model, + :validation_model, + ) + end + end + end +end diff --git a/app/controllers/madmin/settings_controller.rb b/app/controllers/madmin/settings_controller.rb new file mode 100644 index 0000000..981c87d --- /dev/null +++ b/app/controllers/madmin/settings_controller.rb @@ -0,0 +1,41 @@ +module Madmin + class SettingsController < Madmin::ApplicationController + def show + @setting = Setting.instance + end + + def edit + @setting = Setting.instance + end + + def update + @setting = Setting.instance + + if @setting.update(setting_params) + redirect_to main_app.madmin_settings_path, notice: t("controllers.madmin.settings.update.notice") + else + render :edit, status: :unprocessable_entity + end + end + + private + + def setting_params + params.require(:setting).permit( + :github_api_token, + :github_rubycommunity_client_id, + :github_rubycommunity_client_secret, + :github_whyruby_client_id, + :github_whyruby_client_secret, + :litestream_replica_bucket, + :litestream_replica_key_id, + :litestream_replica_access_key, + :public_chats, + :stripe_secret_key, + :stripe_publishable_key, + :stripe_webhook_secret, + :trial_days, + ) + end + end +end diff --git a/app/controllers/madmin/tags_controller.rb b/app/controllers/madmin/tags_controller.rb new file mode 100644 index 0000000..cfd28a4 --- /dev/null +++ b/app/controllers/madmin/tags_controller.rb @@ -0,0 +1,4 @@ +module Madmin + class TagsController < Madmin::ResourceController + end +end diff --git a/app/controllers/madmin/teams_controller.rb b/app/controllers/madmin/teams_controller.rb new file mode 100644 index 0000000..7c94bb4 --- /dev/null +++ b/app/controllers/madmin/teams_controller.rb @@ -0,0 +1,45 @@ +module Madmin + class TeamsController < Madmin::ResourceController + private + + def set_record + @record = resource.model + .includes(memberships: :user, chats: [ :model, :messages ]) + .find_by!(slug: params[:id]) + end + + def scoped_resources + resources = resource.model.send(valid_scope) + resources = Madmin::Search.new(resources, resource, search_term).run + resources = resources.includes(memberships: :user, chats: []) + + dir = sort_direction == "asc" ? "ASC" : "DESC" + + case sort_column + when "owner_name" + resources + .left_joins(memberships: :user) + .where(memberships: { role: "owner" }) + .or(resources.left_joins(memberships: :user).where(memberships: { id: nil })) + .reorder(Arel.sql("users.name #{dir}")) + when "members_count" + resources + .left_joins(:memberships) + .group("teams.id") + .reorder(Arel.sql("COUNT(memberships.id) #{dir}")) + when "chats_count" + resources + .left_joins(:chats) + .group("teams.id") + .reorder(Arel.sql("COUNT(chats.id) #{dir}")) + when "total_cost" + resources + .left_joins(:chats) + .group("teams.id") + .reorder(Arel.sql("COALESCE(SUM(chats.total_cost), 0) #{dir}")) + else + resources.reorder(sort_column => sort_direction) + end + end + end +end diff --git a/app/controllers/madmin/testimonials_controller.rb b/app/controllers/madmin/testimonials_controller.rb new file mode 100644 index 0000000..1e9ec36 --- /dev/null +++ b/app/controllers/madmin/testimonials_controller.rb @@ -0,0 +1,15 @@ +module Madmin + class TestimonialsController < Madmin::ResourceController + private + + def scoped_resources + resources = super.includes(:user) + + if params[:published].present? + resources = resources.where(published: params[:published] == "true") + end + + resources + end + end +end diff --git a/app/controllers/madmin/tool_calls_controller.rb b/app/controllers/madmin/tool_calls_controller.rb new file mode 100644 index 0000000..3aa2e01 --- /dev/null +++ b/app/controllers/madmin/tool_calls_controller.rb @@ -0,0 +1,16 @@ +module Madmin + class ToolCallsController < Madmin::ResourceController + def scoped_resources + resources = super.includes(:message) + + if params[:created_at_from].present? && params[:created_at_to].present? + resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to]) + elsif params[:created_at].present? + date = Date.parse(params[:created_at]) + resources = resources.where("DATE(created_at) = ?", date) + end + + resources + end + end +end diff --git a/app/controllers/madmin/users_controller.rb b/app/controllers/madmin/users_controller.rb new file mode 100644 index 0000000..f2bcc3b --- /dev/null +++ b/app/controllers/madmin/users_controller.rb @@ -0,0 +1,46 @@ +module Madmin + class UsersController < Madmin::ResourceController + def index + super + companies = @records.map(&:company).compact.reject(&:blank?).uniq + @company_teams = Team.where(name: companies).index_by(&:name) + end + + private + + def set_record + @record = resource.model + .includes(:posts, :comments, :testimonial, :projects) + .find(params[:id]) + end + + def scoped_resources + resources = resource.model.send(valid_scope) + resources = Madmin::Search.new(resources, resource, search_term).run + + if params[:created_at_from].present? && params[:created_at_to].present? + resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to]) + elsif params[:created_at].present? + date = Date.parse(params[:created_at]) + resources = resources.where("DATE(created_at) = ?", date) + end + + if params[:role].present? + resources = resources.where(role: params[:role]) + end + + if params[:trusted] == "true" + resources = resources.trusted + end + + dir = sort_direction == "asc" ? "ASC" : "DESC" + + case sort_column + when "published_posts_count", "published_comments_count", "github_stars_sum" + resources.reorder(Arel.sql("#{sort_column} #{dir}")) + else + resources.reorder(sort_column => sort_direction) + end + end + end +end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb new file mode 100644 index 0000000..c0f7839 --- /dev/null +++ b/app/controllers/messages_controller.rb @@ -0,0 +1,36 @@ +class MessagesController < ApplicationController + include Attachable + + before_action :authenticate_user! + before_action :set_chat + + # AI calls are expensive - strict limits + rate_limit to: 20, within: 1.minute, name: "messages/short", only: :create + rate_limit to: 100, within: 1.hour, name: "messages/long", only: :create + + def create + return unless content.present? || attachments.present? + + attachment_paths = store_attachments_temporarily(attachments) + ChatResponseJob.perform_later(@chat.id, content, attachment_paths) + + respond_to do |format| + format.turbo_stream + format.html { redirect_to team_chat_path(current_team, @chat) } + end + end + + private + + def set_chat + @chat = current_user.chats.where(team: current_team).find(params[:chat_id]) + end + + def content + params.dig(:message, :content) || "" + end + + def attachments + params.dig(:message, :attachments) + end +end diff --git a/app/controllers/models/refreshes_controller.rb b/app/controllers/models/refreshes_controller.rb new file mode 100644 index 0000000..b60d5cf --- /dev/null +++ b/app/controllers/models/refreshes_controller.rb @@ -0,0 +1,8 @@ +class Models::RefreshesController < ApplicationController + before_action :authenticate_admin! + + def create + Model.refresh! + redirect_to team_models_path(current_team), notice: t("controllers.models.refreshes.create.notice") + end +end diff --git a/app/controllers/models_controller.rb b/app/controllers/models_controller.rb new file mode 100644 index 0000000..54a8f73 --- /dev/null +++ b/app/controllers/models_controller.rb @@ -0,0 +1,11 @@ +class ModelsController < ApplicationController + before_action :authenticate_user! + + def index + @models = Model.all + end + + def show + @model = Model.find(params[:id]) + end +end diff --git a/app/controllers/og_images_controller.rb b/app/controllers/og_images_controller.rb new file mode 100644 index 0000000..880d99d --- /dev/null +++ b/app/controllers/og_images_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Renders an HTML page at 1200x630 for screenshotting into a static OG image. +# Visit /og-image in a browser, resize to 1200x630, and screenshot. +# Save as public/og-image.png for use in meta tags. +class OgImagesController < ApplicationController + layout false + + def show + @app_name = t("app_name") + @tagline = t("og_image.tagline") + @domain = request.host + end +end diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb new file mode 100644 index 0000000..ff3cce6 --- /dev/null +++ b/app/controllers/onboardings_controller.rb @@ -0,0 +1,35 @@ +class OnboardingsController < ApplicationController + skip_before_action :require_onboarding!, raise: false + before_action :authenticate_user! + before_action :redirect_if_onboarded + layout "onboarding" + + def show + @user = current_user + @team = current_user.teams.first + end + + def update + @user = current_user + @team = current_user.teams.first + + ActiveRecord::Base.transaction do + @user.update!(name: onboarding_params[:name]) + @team.update!(name: onboarding_params[:team_name]) if @team && @user.owner_of?(@team) && onboarding_params[:team_name].present? + end + + redirect_to team_root_path(@team), notice: t("controllers.onboardings.update.notice", name: @user.name) + rescue ActiveRecord::RecordInvalid + render :show, status: :unprocessable_entity + end + + private + + def onboarding_params + params.require(:onboarding).permit(:name, :team_name) + end + + def redirect_if_onboarded + redirect_to root_path if current_user.onboarded? + end +end diff --git a/app/controllers/posts/duplicate_checks_controller.rb b/app/controllers/posts/duplicate_checks_controller.rb new file mode 100644 index 0000000..cd156db --- /dev/null +++ b/app/controllers/posts/duplicate_checks_controller.rb @@ -0,0 +1,45 @@ +class Posts::DuplicateChecksController < ApplicationController + before_action :authenticate_user! + + def create + url = params[:url] + normalized_url = normalize_url_for_checking(url) + exclude_id = params[:exclude_id] || request.request_parameters[:exclude_id] + + existing_post = Post.where(url: normalized_url) + existing_post = existing_post.where.not(id: exclude_id) if exclude_id.present? + existing_post = existing_post.first + + if existing_post + render json: { + duplicate: true, + existing_post: { + id: existing_post.id, + title: existing_post.title, + url: post_path_for(existing_post) + } + } + else + render json: { duplicate: false } + end + end + + private + + def normalize_url_for_checking(url) + return nil unless url.present? + + normalized = url.strip.gsub(/\/+$/, "") + + if normalized.match?(/^http:\/\/(www\.)?(github\.com|twitter\.com|youtube\.com|linkedin\.com|stackoverflow\.com)/i) + normalized = normalized.sub(/^http:/, "https:") + end + + normalized + end + + def post_path_for(post) + return root_path unless post.category + post_path(post.category, post) + end +end diff --git a/app/controllers/posts/images_controller.rb b/app/controllers/posts/images_controller.rb new file mode 100644 index 0000000..7c5230a --- /dev/null +++ b/app/controllers/posts/images_controller.rb @@ -0,0 +1,34 @@ +class Posts::ImagesController < ApplicationController + before_action :set_post + + def show + if @post.featured_image.attached? + og_blob = @post.image_variant(:og) + + if og_blob + send_data og_blob.download, + type: "image/webp", + disposition: "inline" + else + send_data @post.featured_image.download, + type: "image/webp", + disposition: "inline" + end + else + send_file Rails.root.join("public", "og-image.webp"), + type: "image/webp", + disposition: "inline" + end + end + + private + + def set_post + if params[:category_id] + @category = Category.friendly.find(params[:category_id]) + @post = @category.posts.friendly.find(params[:post_id] || params[:id]) + else + @post = Post.friendly.find(params[:post_id] || params[:id]) + end + end +end diff --git a/app/controllers/posts/metadata_controller.rb b/app/controllers/posts/metadata_controller.rb new file mode 100644 index 0000000..e118103 --- /dev/null +++ b/app/controllers/posts/metadata_controller.rb @@ -0,0 +1,61 @@ +class Posts::MetadataController < ApplicationController + before_action :authenticate_user! + + def create + url = params[:url] + exclude_id = params[:exclude_id] || request.request_parameters[:exclude_id] + + normalized_url = normalize_url_for_checking(url) + + existing_post = Post.where(url: normalized_url) + existing_post = existing_post.where.not(id: exclude_id) if exclude_id.present? + existing_post = existing_post.first + + if existing_post + render json: { + success: false, + duplicate: true, + existing_post: { + id: existing_post.id, + title: existing_post.title, + url: post_path_for(existing_post) + } + } + return + end + + begin + post = Post.new(url: url) + result = post.fetch_metadata! + + metadata = { + title: result[:title], + summary: result[:description], + image_url: result[:image_url] + } + + render json: { success: true, metadata: metadata } + rescue => e + render json: { success: false, error: e.message } + end + end + + private + + def normalize_url_for_checking(url) + return nil unless url.present? + + normalized = url.strip.gsub(/\/+$/, "") + + if normalized.match?(/^http:\/\/(www\.)?(github\.com|twitter\.com|youtube\.com|linkedin\.com|stackoverflow\.com)/i) + normalized = normalized.sub(/^http:/, "https:") + end + + normalized + end + + def post_path_for(post) + return root_path unless post.category + post_path(post.category, post) + end +end diff --git a/app/controllers/posts/previews_controller.rb b/app/controllers/posts/previews_controller.rb new file mode 100644 index 0000000..d3d3ad1 --- /dev/null +++ b/app/controllers/posts/previews_controller.rb @@ -0,0 +1,8 @@ +class Posts::PreviewsController < ApplicationController + before_action :authenticate_user! + + def create + html = helpers.markdown_to_html(params[:content]) + render json: { html: html } + end +end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index d02d9cb..45bc621 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -1,37 +1,12 @@ class PostsController < ApplicationController - before_action :authenticate_user!, except: [ :show, :image ] - before_action :set_post, only: [ :show, :edit, :update, :destroy, :image ] + before_action :authenticate_user!, except: [ :show ] + before_action :set_post, only: [ :show, :edit, :update, :destroy ] before_action :authorize_user!, only: [ :edit, :update, :destroy ] def show @comments = @post.comments.published.includes(:user).order(created_at: :asc) end - # Serve images directly for posts with stable URLs for social media - def image - if @post.featured_image.attached? - # Serve the og variant (1200x630 WebP) for social media - og_blob = @post.image_variant(:og) - - if og_blob - send_data og_blob.download, - type: "image/webp", - disposition: "inline" - else - # Fallback to original if og variant not available yet - send_data @post.featured_image.download, - type: "image/webp", - disposition: "inline" - end - else - # Serve default OG image (should also be WebP) - send_file Rails.root.join("public", "og-image.webp"), - type: "image/webp", - disposition: "inline" - end - end - - def new @post = current_user.posts.build @@ -124,84 +99,11 @@ def destroy end end - def preview - html = helpers.markdown_to_html(params[:content]) - render json: { html: html } - end - - def fetch_metadata - url = params[:url] - exclude_id = params[:exclude_id] || request.request_parameters[:exclude_id] - - # Normalize URL for duplicate checking - normalized_url = normalize_url_for_checking(url) - - # Check for existing post (excluding current post if editing) - existing_post = Post.where(url: normalized_url) - existing_post = existing_post.where.not(id: exclude_id) if exclude_id.present? - existing_post = existing_post.first - - if existing_post - render json: { - success: false, - duplicate: true, - existing_post: { - id: existing_post.id, - title: existing_post.title, - url: post_path_for(existing_post) - } - } - return - end - - begin - fetcher = MetadataFetcher.new(url) - result = fetcher.fetch! - - metadata = { - title: result[:title], - summary: result[:description], - image_url: result[:image_url] - } - - render json: { success: true, metadata: metadata } - rescue => e - render json: { success: false, error: e.message } - end - end - - def check_duplicate_url - url = params[:url] - normalized_url = normalize_url_for_checking(url) - - # Handle exclude_id from both regular params and JSON body - exclude_id = params[:exclude_id] || request.request_parameters[:exclude_id] - - existing_post = Post.where(url: normalized_url) - existing_post = existing_post.where.not(id: exclude_id) if exclude_id.present? - existing_post = existing_post.first - - if existing_post - render json: { - duplicate: true, - existing_post: { - id: existing_post.id, - title: existing_post.title, - url: post_path_for(existing_post) - } - } - else - render json: { duplicate: false } - end - end - private def fetch_and_attach_image_from_url(url) return if url.blank? - - # Use ImageProcessor to fetch and process the image - ImageProcessor.process_from_url(url, @post) + @post.attach_image_from_url!(url) end def set_post @@ -222,20 +124,6 @@ def set_post - def normalize_url_for_checking(url) - return nil unless url.present? - - # Strip and remove trailing slashes - normalized = url.strip.gsub(/\/+$/, "") - - # Convert http to https for common domains - if normalized.match?(/^http:\/\/(www\.)?(github\.com|twitter\.com|youtube\.com|linkedin\.com|stackoverflow\.com)/i) - normalized = normalized.sub(/^http:/, "https:") - end - - normalized - end - def authorize_user! unless @post.user == current_user || current_user.admin? redirect_to root_path, alert: "Not authorized" @@ -272,6 +160,7 @@ def process_tags end def post_path_for(post) + return root_path unless post.category post_path(post.category, post) end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 0000000..33d5dae --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,30 @@ +class ProfilesController < ApplicationController + before_action :authenticate_user! + + def show + @user = current_user + @user_language = Language.find_by(code: @user.locale) if @user.locale.present? + end + + def edit + @user = current_user + @languages = Language.enabled.by_name + end + + def update + @user = current_user + + if @user.update(profile_params) + redirect_to team_profile_path(current_team), notice: t("controllers.profiles.update.notice") + else + @languages = Language.enabled.by_name + render :edit, status: :unprocessable_entity + end + end + + private + + def profile_params + params.require(:user).permit(:name, :locale, :avatar, :remove_avatar) + end +end diff --git a/app/controllers/sessions/verifications_controller.rb b/app/controllers/sessions/verifications_controller.rb new file mode 100644 index 0000000..7987447 --- /dev/null +++ b/app/controllers/sessions/verifications_controller.rb @@ -0,0 +1,80 @@ +class Sessions::VerificationsController < ApplicationController + rate_limit to: 10, within: 5.minutes, name: "sessions/verify", + with: -> { redirect_to root_path, alert: t("controllers.sessions.rate_limit.verify") } + + def show + user = User.find_signed!(params[:token], purpose: :magic_link) + + if params[:team].present? + handle_team_invitation(user, params[:team], params[:invited_by]) + end + + session[:user_id] = user.id + save_locale_from_header(user) if user.locale.nil? + + if user.onboarded? + redirect_to after_login_path(user, params[:team]), notice: t("controllers.sessions.verify.notice", name: user.name) + else + ensure_team_exists(user, params[:team]) + redirect_to onboarding_path + end + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to root_path, alert: t("controllers.sessions.verify.alert") + end + + private + + def handle_team_invitation(user, team_slug, invited_by_id) + team = Team.find_by(slug: team_slug) + return unless team + + invited_by = User.find_by(id: invited_by_id) + + unless user.member_of?(team) + user.memberships.create!(team: team, invited_by: invited_by, role: "member") + end + end + + def after_login_path(user, invited_team_slug = nil) + if invited_team_slug.present? + team = Team.find_by(slug: invited_team_slug) + return team_root_path(team) if team && user.member_of?(team) + end + + teams = user.teams + + case teams.size + when 0 + team = create_personal_team(user) + team_root_path(team) + when 1 + team_root_path(teams.first) + else + teams_path + end + end + + def ensure_team_exists(user, invited_team_slug = nil) + return if user.teams.exists? + + create_personal_team(user) + end + + def create_personal_team(user) + Team.find_or_create_for_user!(user) + end + + def save_locale_from_header(user) + return unless request.headers["Accept-Language"] + + accepted = parse_accept_language(request.headers["Accept-Language"]) + enabled = Language.enabled_codes + + accepted.each do |code| + if enabled.include?(code) + user.update_column(:locale, code) + return + end + end + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..82e8913 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,32 @@ +class SessionsController < ApplicationController + # Short-term: prevent rapid-fire attempts + rate_limit to: 5, within: 1.minute, name: "sessions/short", only: :create, + with: -> { redirect_to new_session_path, alert: t("controllers.sessions.rate_limit.short") } + + # Long-term: prevent sustained attacks + rate_limit to: 20, within: 1.hour, name: "sessions/long", only: :create, + with: -> { redirect_to new_session_path, alert: t("controllers.sessions.rate_limit.long") } + + def new + redirect_to root_path if current_user + end + + def create + email = params.expect(session: :email)[:email] + user = User.find_by(email: email) + + # Create user if doesn't exist (first magic link creates the account) + # Name is collected during onboarding after first login + user ||= User.create!(email: email) + + # Send magic link + UserMailer.magic_link(user).deliver_later + + redirect_to new_session_path, notice: t("controllers.sessions.create.notice") + end + + def destroy + reset_session + redirect_to new_session_path, notice: t("controllers.sessions.destroy.notice") + end +end diff --git a/app/controllers/tags/searches_controller.rb b/app/controllers/tags/searches_controller.rb new file mode 100644 index 0000000..e3336da --- /dev/null +++ b/app/controllers/tags/searches_controller.rb @@ -0,0 +1,15 @@ +class Tags::SearchesController < ApplicationController + def show + query = params[:q].to_s.strip.downcase + + if query.present? + tags = Tag.where("LOWER(name) LIKE ?", "%#{query}%") + .order(:name) + .limit(10) + + render json: tags.map { |tag| { id: tag.id, name: tag.name } } + else + render json: [] + end + end +end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 3477f50..4d77240 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -4,18 +4,4 @@ def show @posts = @tag.posts.published.includes(:user, :category) .page(params[:page]) end - - def search - query = params[:q].to_s.strip.downcase - - if query.present? - tags = Tag.where("LOWER(name) LIKE ?", "%#{query}%") - .order(:name) - .limit(10) - - render json: tags.map { |tag| { id: tag.id, name: tag.name } } - else - render json: [] - end - end end diff --git a/app/controllers/teams/billing_controller.rb b/app/controllers/teams/billing_controller.rb new file mode 100644 index 0000000..eb6e9a8 --- /dev/null +++ b/app/controllers/teams/billing_controller.rb @@ -0,0 +1,13 @@ +class Teams::BillingController < ApplicationController + before_action :authenticate_user! + before_action :require_team_admin! + + def show + if current_team.stripe_customer_id.present? + portal = current_team.create_billing_portal_session( + return_url: team_billing_url(current_team) + ) + @portal_url = portal.url + end + end +end diff --git a/app/controllers/teams/checkouts_controller.rb b/app/controllers/teams/checkouts_controller.rb new file mode 100644 index 0000000..63fe7f0 --- /dev/null +++ b/app/controllers/teams/checkouts_controller.rb @@ -0,0 +1,13 @@ +class Teams::CheckoutsController < ApplicationController + before_action :authenticate_user! + before_action :require_team_admin! + + def create + session = current_team.create_checkout_session( + price_id: params[:price_id], + success_url: team_billing_url(current_team), + cancel_url: team_pricing_url(current_team) + ) + redirect_to session.url, allow_other_host: true + end +end diff --git a/app/controllers/teams/languages_controller.rb b/app/controllers/teams/languages_controller.rb new file mode 100644 index 0000000..99545a4 --- /dev/null +++ b/app/controllers/teams/languages_controller.rb @@ -0,0 +1,28 @@ +class Teams::LanguagesController < ApplicationController + before_action :authenticate_user! + before_action :require_team_admin! + + def index + @active_languages = current_team.team_languages.active.includes(:language).map(&:language) + @available_languages = Language.enabled.where.not(id: @active_languages.map(&:id)).by_name + end + + def create + language = Language.enabled.find(params[:language_id]) + current_team.enable_language!(language) + BackfillTranslationsJob.perform_later(current_team.id, language.code) + redirect_to team_languages_path(current_team), notice: t("controllers.teams.languages.create.notice", language: language.localized_name) + end + + def destroy + language = Language.find(params[:id]) + + if current_team.team_languages.active.count <= 1 + redirect_to team_languages_path(current_team), alert: t("controllers.teams.languages.destroy.cannot_remove_last") + return + end + + current_team.disable_language!(language) + redirect_to team_languages_path(current_team), notice: t("controllers.teams.languages.destroy.notice", language: language.localized_name) + end +end diff --git a/app/controllers/teams/members_controller.rb b/app/controllers/teams/members_controller.rb new file mode 100644 index 0000000..f9fd528 --- /dev/null +++ b/app/controllers/teams/members_controller.rb @@ -0,0 +1,48 @@ +class Teams::MembersController < ApplicationController + before_action :authenticate_user! + before_action :require_team_admin!, only: [ :new, :create, :destroy ] + + def index + @memberships = current_team.memberships.includes(:user, :invited_by) + end + + def show + @membership = current_team.memberships.includes(:user, :invited_by).find(params[:id]) + end + + def new + @invite_email = params[:email] + end + + def create + email = params[:email] + user = User.find_or_initialize_by(email: email) + + # Name is collected during onboarding after first login + user.save! if user.new_record? + + if user.member_of?(current_team) + redirect_to team_members_path(current_team), alert: t("controllers.teams.members.already_member") + return + end + + token = user.signed_id(purpose: :magic_link, expires_in: 7.days) + invite_url = verify_magic_link_url(token: token, team: current_team.slug, invited_by: current_user.id) + + UserMailer.team_invitation(user, current_team, current_user, invite_url).deliver_later + + redirect_to team_members_path(current_team), notice: t("controllers.teams.members.create.notice", email: email) + end + + def destroy + membership = current_team.memberships.find(params[:id]) + + if membership.owner? && current_team.memberships.where(role: "owner").count == 1 + redirect_to team_members_path(current_team), alert: t("controllers.teams.members.cannot_remove_last_owner") + return + end + + membership.destroy + redirect_to team_members_path(current_team), notice: t("controllers.teams.members.destroy.removed", name: membership.user.name) + end +end diff --git a/app/controllers/teams/name_checks_controller.rb b/app/controllers/teams/name_checks_controller.rb new file mode 100644 index 0000000..c6b4fc4 --- /dev/null +++ b/app/controllers/teams/name_checks_controller.rb @@ -0,0 +1,11 @@ +class Teams::NameChecksController < ApplicationController + before_action :authenticate_user! + before_action :require_team_admin! + + def show + name = params[:name].to_s.strip + taken = name.present? && Team.where.not(id: current_team.id).exists?(name: name) + + render json: { available: !taken } + end +end diff --git a/app/controllers/teams/pricing_controller.rb b/app/controllers/teams/pricing_controller.rb new file mode 100644 index 0000000..4e370e2 --- /dev/null +++ b/app/controllers/teams/pricing_controller.rb @@ -0,0 +1,9 @@ +class Teams::PricingController < ApplicationController + before_action :authenticate_user! + before_action :require_team_admin! + + def show + @prices = Price.all + @monthly_prices, @yearly_prices = @prices.partition { |p| p.interval == "month" } + end +end diff --git a/app/controllers/teams/settings/api_key_regenerations_controller.rb b/app/controllers/teams/settings/api_key_regenerations_controller.rb new file mode 100644 index 0000000..1cd5457 --- /dev/null +++ b/app/controllers/teams/settings/api_key_regenerations_controller.rb @@ -0,0 +1,9 @@ +class Teams::Settings::ApiKeyRegenerationsController < ApplicationController + before_action :authenticate_user! + before_action :require_team_admin! + + def create + current_team.regenerate_api_key! + redirect_to team_settings_path(current_team), notice: t("controllers.teams.settings.regenerate_api_key.notice") + end +end diff --git a/app/controllers/teams/settings_controller.rb b/app/controllers/teams/settings_controller.rb new file mode 100644 index 0000000..9e0bc50 --- /dev/null +++ b/app/controllers/teams/settings_controller.rb @@ -0,0 +1,28 @@ +class Teams::SettingsController < ApplicationController + before_action :authenticate_user! + before_action :require_team_admin! + + def show + end + + def edit + end + + def update + current_team.assign_attributes(team_params) + + if current_team.save + redirect_to team_settings_path(current_team.slug), notice: t("controllers.teams.settings.update.notice") + else + @team_form = current_team.dup.tap { |t| t.errors.merge!(current_team.errors) } + current_team.reload + render :edit, status: :unprocessable_entity + end + end + + private + + def team_params + params.require(:team).permit(:name, :logo, :remove_logo) + end +end diff --git a/app/controllers/teams/subscription_cancellations_controller.rb b/app/controllers/teams/subscription_cancellations_controller.rb new file mode 100644 index 0000000..63a9745 --- /dev/null +++ b/app/controllers/teams/subscription_cancellations_controller.rb @@ -0,0 +1,14 @@ +class Teams::SubscriptionCancellationsController < ApplicationController + before_action :authenticate_user! + before_action :require_team_admin! + + def create + current_team.cancel_subscription! + redirect_to team_billing_path(current_team), notice: t("controllers.teams.subscription_cancellations.create.notice") + end + + def destroy + current_team.resume_subscription! + redirect_to team_billing_path(current_team), notice: t("controllers.teams.subscription_cancellations.destroy.notice") + end +end diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb new file mode 100644 index 0000000..7827172 --- /dev/null +++ b/app/controllers/teams_controller.rb @@ -0,0 +1,39 @@ +class TeamsController < ApplicationController + before_action :authenticate_user! + + def index + @teams = current_user.teams + + if @teams.one? + redirect_to team_root_path(@teams.first) + elsif @teams.none? + team = create_personal_team(current_user) + redirect_to team_root_path(team) + end + end + + def new + @team = Team.new + end + + def create + @team = Team.new(team_params) + + if @team.save + @team.memberships.create!(user: current_user, role: "owner") + redirect_to team_root_path(@team), notice: t("controllers.teams.create.notice") + else + render :new, status: :unprocessable_entity + end + end + + private + + def team_params + params.require(:team).permit(:name) + end + + def create_personal_team(user) + Team.find_or_create_for_user!(user) + end +end diff --git a/app/controllers/user_settings/newsletters_controller.rb b/app/controllers/user_settings/newsletters_controller.rb new file mode 100644 index 0000000..32d0f15 --- /dev/null +++ b/app/controllers/user_settings/newsletters_controller.rb @@ -0,0 +1,16 @@ +class UserSettings::NewslettersController < ApplicationController + before_action :authenticate_user! + + def update + current_user.update!(unsubscribed_from_newsletter: !current_user.unsubscribed_from_newsletter) + respond_to do |format| + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }), + turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" }) + ] + end + format.html { redirect_to user_path(current_user) } + end + end +end diff --git a/app/controllers/user_settings/open_to_works_controller.rb b/app/controllers/user_settings/open_to_works_controller.rb new file mode 100644 index 0000000..9ce6ace --- /dev/null +++ b/app/controllers/user_settings/open_to_works_controller.rb @@ -0,0 +1,18 @@ +class UserSettings::OpenToWorksController < ApplicationController + before_action :authenticate_user! + + def update + current_user.update!(open_to_work: !current_user.open_to_work) + respond_to do |format| + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }), + turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" }), + turbo_stream.replace("profile_avatar_desktop", partial: "users/profile_avatar", locals: { user: current_user, wrapper_id: "profile_avatar_desktop", size: "w-36 h-[130px]", text_size: "text-5xl" }), + turbo_stream.replace("profile_avatar_mobile", partial: "users/profile_avatar", locals: { user: current_user, wrapper_id: "profile_avatar_mobile", size: "w-28 h-[100px]", text_size: "text-4xl" }) + ] + end + format.html { redirect_to user_path(current_user) } + end + end +end diff --git a/app/controllers/user_settings/repository_hides_controller.rb b/app/controllers/user_settings/repository_hides_controller.rb new file mode 100644 index 0000000..7663be6 --- /dev/null +++ b/app/controllers/user_settings/repository_hides_controller.rb @@ -0,0 +1,34 @@ +class UserSettings::RepositoryHidesController < ApplicationController + before_action :authenticate_user! + + def create + repo_url = params.expect(:repo_url) + current_user.hide_repository!(repo_url) + render_projects_update + end + + def destroy + repo_url = params.expect(:repo_url) + current_user.unhide_repository!(repo_url) + render_projects_update + end + + private + + def render_projects_update + current_user.reload + @ruby_repos = current_user.visible_ruby_repositories + @hidden_repos = current_user.hidden_ruby_repositories + + respond_to do |format| + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace("projects_panel", partial: "users/projects_panel", locals: { ruby_repos: @ruby_repos, hidden_repos: @hidden_repos, user: current_user }), + turbo_stream.update("projects_count", html: @ruby_repos.size.to_s), + turbo_stream.replace("profile_stars", partial: "users/profile_stars", locals: { user: current_user }) + ] + end + format.html { redirect_to user_path(current_user) } + end + end +end diff --git a/app/controllers/user_settings/visibilities_controller.rb b/app/controllers/user_settings/visibilities_controller.rb new file mode 100644 index 0000000..65b8dc9 --- /dev/null +++ b/app/controllers/user_settings/visibilities_controller.rb @@ -0,0 +1,16 @@ +class UserSettings::VisibilitiesController < ApplicationController + before_action :authenticate_user! + + def update + current_user.update!(public: !current_user.public) + respond_to do |format| + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }), + turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" }) + ] + end + format.html { redirect_to user_path(current_user) } + end + end +end diff --git a/app/controllers/user_settings_controller.rb b/app/controllers/user_settings_controller.rb deleted file mode 100644 index 6113395..0000000 --- a/app/controllers/user_settings_controller.rb +++ /dev/null @@ -1,75 +0,0 @@ -class UserSettingsController < ApplicationController - before_action :authenticate_user! - - def toggle_public - current_user.update!(public: !current_user.public) - respond_to do |format| - format.turbo_stream do - render turbo_stream: [ - turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }), - turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" }) - ] - end - format.html { redirect_to user_path(current_user) } - end - end - - def toggle_open_to_work - current_user.update!(open_to_work: !current_user.open_to_work) - respond_to do |format| - format.turbo_stream do - render turbo_stream: [ - turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }), - turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" }), - turbo_stream.replace("profile_avatar_desktop", partial: "users/profile_avatar", locals: { user: current_user, wrapper_id: "profile_avatar_desktop", size: "w-36 h-[130px]", text_size: "text-5xl" }), - turbo_stream.replace("profile_avatar_mobile", partial: "users/profile_avatar", locals: { user: current_user, wrapper_id: "profile_avatar_mobile", size: "w-28 h-[100px]", text_size: "text-4xl" }) - ] - end - format.html { redirect_to user_path(current_user) } - end - end - - def toggle_newsletter - current_user.update!(unsubscribed_from_newsletter: !current_user.unsubscribed_from_newsletter) - respond_to do |format| - format.turbo_stream do - render turbo_stream: [ - turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }), - turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" }) - ] - end - format.html { redirect_to user_path(current_user) } - end - end - - def hide_repo - repo_url = params.expect(:repo_url) - current_user.hide_repository!(repo_url) - render_projects_update - end - - def unhide_repo - repo_url = params.expect(:repo_url) - current_user.unhide_repository!(repo_url) - render_projects_update - end - - private - - def render_projects_update - current_user.reload # Reload to get updated stars count - @ruby_repos = current_user.visible_ruby_repositories - @hidden_repos = current_user.hidden_ruby_repositories - - respond_to do |format| - format.turbo_stream do - render turbo_stream: [ - turbo_stream.replace("projects_panel", partial: "users/projects_panel", locals: { ruby_repos: @ruby_repos, hidden_repos: @hidden_repos, user: current_user }), - turbo_stream.update("projects_count", html: @ruby_repos.size.to_s), - turbo_stream.replace("profile_stars", partial: "users/profile_stars", locals: { user: current_user }) - ] - end - format.html { redirect_to user_path(current_user) } - end - end -end diff --git a/app/controllers/users/map_data_controller.rb b/app/controllers/users/map_data_controller.rb new file mode 100644 index 0000000..e70ebc1 --- /dev/null +++ b/app/controllers/users/map_data_controller.rb @@ -0,0 +1,25 @@ +class Users::MapDataController < ApplicationController + def show + data = Rails.cache.fetch("community_map_data", expires_in: 1.hour) do + User.visible + .where.not(latitude: nil, longitude: nil) + .select(:id, :slug, :username, :name, :avatar_url, :latitude, :longitude, :open_to_work, :company, :normalized_location) + .map { |u| + { + id: u.id, + name: u.display_name, + username: u.username, + avatar_url: u.avatar_url, + lat: u.latitude, + lng: u.longitude, + open_to_work: u.open_to_work, + company: u.company, + normalized_location: u.normalized_location, + profile_url: helpers.community_user_url(u) + } + } + end + + render json: data + end +end diff --git a/app/controllers/users/og_images_controller.rb b/app/controllers/users/og_images_controller.rb new file mode 100644 index 0000000..8a2bbe5 --- /dev/null +++ b/app/controllers/users/og_images_controller.rb @@ -0,0 +1,9 @@ +class Users::OgImagesController < ApplicationController + def show + @users = User.where(public: true) + .where.not(avatar_url: [ nil, "" ]) + .order(Arel.sql("COALESCE(github_stars_sum, 0) + COALESCE(published_posts_count, 0) * 10 + COALESCE(published_comments_count, 0) DESC")) + @total_users_count = User.visible.count + render layout: false + end +end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index e72ffe4..481e5fb 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -1,16 +1,15 @@ -class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController +class Users::OmniauthCallbacksController < ApplicationController skip_before_action :verify_authenticity_token, only: :github def github - @user = User.from_omniauth(request.env["omniauth.auth"]) + auth = request.env["omniauth.auth"] + @user = User.from_omniauth(auth) if @user.persisted? - sign_in @user, event: :authentication - set_flash_message(:notice, :success, kind: "GitHub") if is_navigational_format? + sign_in @user redirect_to after_sign_in_path, allow_other_host: true else - session["devise.github_data"] = request.env["omniauth.auth"].except(:extra) - redirect_to new_user_registration_url + redirect_to root_path, alert: "Authentication failed." end end @@ -23,10 +22,8 @@ def failure def after_sign_in_path # Get the original page user was on (stored in session before OAuth) return_to = session.delete(:return_to) - session.delete(:from_community) # Clean up, not used anymore # Determine final destination - # If specific return_to is set, use it; otherwise go to user profile final_destination = return_to.presence || user_profile_path # In production, sync session to other domain first, then return to original page @@ -36,11 +33,8 @@ def after_sign_in_path current_host = request.host token = @user.generate_cross_domain_token! - # Build full URL for final destination - # If final_destination is already a full URL (from user_profile_path), use it directly final_url = final_destination.start_with?("https://") ? final_destination : "https://#{current_host}#{final_destination}" - # Redirect to other domain to sync session, passing final destination "https://#{other_host}/auth/receive?token=#{token}&return_to=#{CGI.escape(final_url)}" else final_destination @@ -48,8 +42,6 @@ def after_sign_in_path end def user_profile_path - # In development: /community/:username - # In production: always go to rubycommunity.org/:username domains = Rails.application.config.x.domains if Rails.env.production? "https://#{domains.community}/#{@user.to_param}" diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index ad5f9b7..379663d 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -1,19 +1,14 @@ -class Users::SessionsController < Devise::SessionsController +class Users::SessionsController < ApplicationController def github_auth # Store the return_to path in session session[:return_to] = params[:return_to] if params[:return_to].present? - # Detect if signing in from community pages (to redirect to profile after sign in) - session[:from_community] = from_community_page? - - redirect_to user_github_omniauth_authorize_path, allow_other_host: true + redirect_to "/auth/github", allow_other_host: true end def destroy domains = Rails.application.config.x.domains - # Determine where to redirect after sign out - # Community pages -> community index, otherwise -> home return_to_path = from_community_page? ? users_path : "/" other_host = (request.host == domains.community) ? domains.primary : domains.community @@ -22,17 +17,14 @@ def destroy # Generate token for cross-domain sign out token = current_user&.generate_cross_domain_token! - signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)) - set_flash_message! :notice, :signed_out if signed_out + sign_out if Rails.env.production? && token - # Sign out on other domain, then come back to this domain - # On community domain, return to root "/"; on primary domain, redirect to community domain root prod_return_path = (current_host == domains.community) ? "/" : "https://#{domains.community}/" final_destination = from_community_page? ? prod_return_path : "https://#{current_host}/" redirect_to "https://#{other_host}/auth/sign_out_receive?token=#{token}&return_to=#{CGI.escape(final_destination)}", allow_other_host: true else - redirect_to return_to_path + redirect_to return_to_path, notice: "Signed out successfully." end end @@ -40,13 +32,9 @@ def destroy def from_community_page? domains = Rails.application.config.x.domains - # Check if on community domain return true if request.host == domains.community - - # Check if current path or referer is a community path return true if request.path.start_with?("/community") - # Check referer for community pages (for sign-in button clicks) if request.referer.present? referer_uri = URI.parse(request.referer) rescue nil return true if referer_uri && (referer_uri.host == domains.community || referer_uri.path.start_with?("/community")) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9f7eb69..e6a73eb 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -82,38 +82,6 @@ def index @users = @users.page(params[:page]).per(20) end - def map_data - data = Rails.cache.fetch("community_map_data", expires_in: 1.hour) do - User.visible - .where.not(latitude: nil, longitude: nil) - .select(:id, :slug, :username, :name, :avatar_url, :latitude, :longitude, :open_to_work, :company, :normalized_location) - .map { |u| - { - id: u.id, - name: u.display_name, - username: u.username, - avatar_url: u.avatar_url, - lat: u.latitude, - lng: u.longitude, - open_to_work: u.open_to_work, - company: u.company, - normalized_location: u.normalized_location, - profile_url: helpers.community_user_url(u) - } - } - end - - render json: data - end - - def og_image - @users = User.where(public: true) - .where.not(avatar_url: [ nil, "" ]) - .order(Arel.sql("COALESCE(github_stars_sum, 0) + COALESCE(published_posts_count, 0) * 10 + COALESCE(published_comments_count, 0) DESC")) - @total_users_count = User.visible.count - render layout: false - end - def show @user = User.friendly.find(params[:id]) diff --git a/app/controllers/webhooks/stripe_controller.rb b/app/controllers/webhooks/stripe_controller.rb new file mode 100644 index 0000000..931e1ac --- /dev/null +++ b/app/controllers/webhooks/stripe_controller.rb @@ -0,0 +1,57 @@ +class Webhooks::StripeController < ActionController::Base + skip_forgery_protection + + def create + payload = request.body.read + sig_header = request.env["HTTP_STRIPE_SIGNATURE"] + + begin + event = Stripe::Webhook.construct_event( + payload, sig_header, Setting.get(:stripe_webhook_secret) + ) + rescue JSON::ParserError, Stripe::SignatureVerificationError + head :bad_request + return + end + + case event.type + when "checkout.session.completed" + handle_checkout_completed(event.data.object) + when "customer.subscription.updated" + handle_subscription_updated(event.data.object) + when "customer.subscription.deleted" + handle_subscription_deleted(event.data.object) + end + + head :ok + end + + private + + def handle_checkout_completed(session) + team = Team.find_by(stripe_customer_id: session.customer) + return unless team + + team.update!(stripe_subscription_id: session.subscription) + team.sync_subscription_from_stripe! + end + + def handle_subscription_updated(subscription) + team = Team.find_by(stripe_customer_id: subscription.customer) + return unless team + + team.update!(stripe_subscription_id: subscription.id) unless team.stripe_subscription_id.present? + team.sync_subscription_from_stripe! + end + + def handle_subscription_deleted(subscription) + team = Team.find_by(stripe_customer_id: subscription.customer) + return unless team + + team.update!( + subscription_status: "canceled", + stripe_subscription_id: nil, + cancel_at_period_end: false + ) + end +end diff --git a/app/helpers/admins/admins_helper.rb b/app/helpers/admins/admins_helper.rb new file mode 100644 index 0000000..fbe772f --- /dev/null +++ b/app/helpers/admins/admins_helper.rb @@ -0,0 +1,2 @@ +module Admins::AdminsHelper +end diff --git a/app/helpers/admins/sessions_helper.rb b/app/helpers/admins/sessions_helper.rb new file mode 100644 index 0000000..64b1d80 --- /dev/null +++ b/app/helpers/admins/sessions_helper.rb @@ -0,0 +1,2 @@ +module Admins::SessionsHelper +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0ea17cb..0de65b2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,6 +1,24 @@ module ApplicationHelper include ImageHelper + # ── Open Graph helpers ── + def og_title + content_for(:og_title).presence || content_for(:title).presence || t("app_name", default: "Why Ruby?") + end + + def og_description + content_for(:og_description).presence || t("meta.default.summary", default: "") + end + + def og_image + if content_for?(:og_image) + src = content_for(:og_image) + src.start_with?("http") ? src : "#{request.base_url}#{src}" + else + versioned_og_image_url + end + end + # Convert ISO country code to full name via i18n def country_name(code) return nil if code.blank? @@ -19,6 +37,12 @@ def country_name_from_location(normalized_location) country_name(code) end + # ── Analytics ── + + def nullitics_enabled? + Rails.configuration.x.nullitics rescue true + end + # Get client country code for analytics (ISO 3166-1 alpha-2, e.g., "US", "DE", "CA") def client_country_code return @client_country_code if defined?(@client_country_code) @@ -31,13 +55,32 @@ def client_country_code nil end end + + # ── Markdown ── + + class MarkdownRenderer < Redcarpet::Render::HTML + include Rouge::Plugins::Redcarpet + + def block_code(code, language) + language ||= "text" + formatter = Rouge::Formatters::HTMLLegacy.new(css_class: "highlight") + lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new + formatter.format(lexer.lex(code)) + end + end + # Class-level memoized markdown renderer for performance def self.markdown_renderer @markdown_renderer ||= begin - renderer = Redcarpet::Render::HTML.new( + renderer = MarkdownRenderer.new( filter_html: true, hard_wrap: true, - link_attributes: { rel: "nofollow", target: "_blank" } + link_attributes: { rel: "nofollow", target: "_blank" }, + fenced_code_blocks: true, + prettify: true, + tables: true, + with_toc_data: true, + no_intra_emphasis: true ) Redcarpet::Markdown.new(renderer, @@ -57,37 +100,13 @@ def self.markdown_renderer end end - def markdown_to_html(markdown_text) - return "" if markdown_text.blank? - - # Render markdown and apply syntax highlighting - html = ApplicationHelper.markdown_renderer.render(markdown_text) - - # Apply syntax highlighting to code blocks - doc = Nokogiri::HTML::DocumentFragment.parse(html) - doc.css("pre code").each do |code_block| - # Extract language from class attribute (e.g., "ruby" from class="ruby" or "language-ruby") - language_class = code_block["class"] || "" - language = language_class.gsub(/^(language-)?/, "") || "text" - - begin - lexer = Rouge::Lexer.find(language) || Rouge::Lexers::PlainText.new - formatter = Rouge::Formatters::HTML.new - highlighted_code = formatter.format(lexer.lex(code_block.text)) - - # Create a new pre element with the highlight class - pre_element = code_block.parent - pre_element["class"] = "highlight highlight-#{language}" - - # Replace the code block's content with the highlighted version - code_block.inner_html = highlighted_code - rescue => e - # If highlighting fails, keep the original code block - Rails.logger.error "Syntax highlighting failed for language '#{language}': #{e.message}" - end - end + def markdown(text) + return "" if text.blank? + ApplicationHelper.markdown_renderer.render(text).html_safe + end - doc.to_html + def markdown_to_html(markdown_text) + markdown(markdown_text) end def format_post_date(date) @@ -124,14 +143,12 @@ def post_link_url(post) end # Generate full URL to post on primary domain (whyruby.info) - # Used to ensure posts always link to the content domain, not the community domain def primary_domain_post_url(post) domain = Rails.application.config.x.domains.primary "https://#{domain}/#{post.category.to_param}/#{post.to_param}" end # Generate edit post URL on primary domain - # Used to ensure edit links always go to whyruby.info, not the community domain def primary_domain_edit_post_url(post) if Rails.env.production? domain = Rails.application.config.x.domains.primary @@ -142,7 +159,6 @@ def primary_domain_edit_post_url(post) end # Generate delete post URL on primary domain - # Used to ensure delete actions always go to whyruby.info, not the community domain def primary_domain_destroy_post_url(post) if Rails.env.production? domain = Rails.application.config.x.domains.primary @@ -181,79 +197,53 @@ def category_menu_active?(category) false end - # Since success stories now work like regular categories, we can remove this method - # and just use category_menu_active? for success stories too - def community_menu_active? - # Highlight if on the users index page return true if current_page?(users_path) - - # Highlight if viewing a user profile controller_name == "users" && action_name == "show" end def safe_external_url(url) return "#" if url.blank? - # Parse the URL and validate it begin uri = URI.parse(url) - - # Only allow http, https, and mailto schemes allowed_schemes = %w[http https mailto] return "#" unless allowed_schemes.include?(uri.scheme&.downcase) - - # Return the original URL if it's safe url rescue URI::InvalidURIError - # If the URL is invalid, return a safe fallback "#" end end def safe_svg_content(svg_content) - # This helper makes it explicit that SVG content has been sanitized - # The actual sanitization happens in the model via SvgSanitizer - # The SVG is already sanitized, so we can safely mark it as html_safe return "" if svg_content.blank? svg_content.html_safe end def safe_markdown_content(markdown_text) - # This helper makes it explicit that markdown has been safely rendered - # with HTML filtering enabled markdown_to_html(markdown_text).html_safe end # Linkify URLs and GitHub @mentions in user bio text - # - URLs like "example.com" become clickable links - # - @username becomes a link to https://github.com/username def linkify_bio(text) return "" if text.blank? - # Escape HTML to prevent XSS escaped = ERB::Util.html_escape(text) - # Pattern for GitHub @mentions github_pattern = /(?<=\s|^)@([a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?)/ - - # Pattern for URLs (with or without protocol) - # Excludes trailing punctuation like commas and periods used in prose url_pattern = %r{ (?:https?://)? # Optional protocol (?:www\.)? # Optional www [a-zA-Z0-9][a-zA-Z0-9\-]* # Domain name \.[a-zA-Z]{2,} # TLD - (?:/[^\s,.<>]*)? # Optional path (stops at whitespace, comma, period, angle brackets) + (?:/[^\s,<>]*[^\s,.<>])? # Optional path (allow dots mid-path, not trailing) }x - # Replace GitHub @mentions first result = escaped.gsub(github_pattern) do |match| username = Regexp.last_match(1) %(#{match}) end - # Replace URLs (skip github.com since @mentions already handled) result = result.gsub(url_pattern) do |match| next match if match.include?("github.com") url = match.start_with?("http") ? match : "https://#{match}" @@ -264,7 +254,6 @@ def linkify_bio(text) end def has_success_stories? - # Cache the result for the request to avoid multiple DB queries if Category.success_story_category @has_success_stories ||= Category.success_story_category.posts.published.exists? else @@ -273,15 +262,10 @@ def has_success_stories? end def should_show_mobile_cta? - # Show CTA when nav is collapsed (below lg) if user is not signed in - # or if their testimonial is not published return true unless user_signed_in? - - # Check if user has a published testimonial !current_user.testimonial&.published? end - # Generate the full formatted page title that matches the tag format def full_page_title(page_title = nil) if page_title.present? "Why Ruby? — #{page_title}" @@ -296,13 +280,10 @@ def full_page_title(page_title = nil) hash[filename] = File.exist?(path) ? File.mtime(path).to_i.to_s : Time.current.to_i.to_s end - # Generate versioned URL for OG image to bust social media caches - # Pass a filename to use a different image (e.g., "og-image-community.png") def versioned_og_image_url(filename = "og-image.png") "#{request.base_url}/#{filename}?v=#{OG_IMAGE_VERSIONS[filename]}" end - # Generate the full page title for community pages (Ruby Community branding) def community_page_title(page_title = nil) if page_title.present? "Ruby Community — #{page_title}" @@ -313,10 +294,12 @@ def community_page_title(page_title = nil) # URL helpers for the new routing structure def post_url_for(post) + return root_url unless post.category post_url(post.category, post) end def post_path_for(post) + return root_path unless post.category post_path(post.category, post) end @@ -324,17 +307,14 @@ def post_path_for(post) def cross_domain_url(domain_type, path = "/") return path unless Rails.env.production? - # No Warden context (e.g. rendering from a background job broadcast) - return path unless respond_to?(:request) && request.present? && request.env["warden"].present? + return path unless respond_to?(:request) && request.present? domains = Rails.application.config.x.domains host = (domain_type == :primary) ? domains.primary : domains.community - # If already on target domain, just return the path return path if request.host == host if user_signed_in? - # Sync session to target domain (memoize token for this request) token = cross_domain_token_for_request "https://#{host}/auth/receive?token=#{token}&return_to=#{path}" else @@ -342,21 +322,16 @@ def cross_domain_url(domain_type, path = "/") end end - # Memoize token per request so multiple links use the same token def cross_domain_token_for_request @cross_domain_token ||= current_user.generate_cross_domain_token! end - # Helper for community index URL (works in dev and prod) def community_index_url return users_path unless Rails.env.production? domain = Rails.application.config.x.domains.community - - # In production on community domain, just go to root return "/" if request.host == domain - # On primary domain, cross-domain to community if user_signed_in? token = current_user.generate_cross_domain_token! "https://#{domain}/auth/receive?token=#{token}&return_to=/" @@ -365,8 +340,6 @@ def community_index_url end end - # Helper for community index path with query params (for pagination/filtering) - # On community domain in production, uses root path. Otherwise uses /community. def community_index_path(params = {}) base_path = if Rails.env.production? && request.host == Rails.application.config.x.domains.community "/" @@ -380,7 +353,6 @@ def community_index_path(params = {}) query.present? ? "#{base_path}?#{query}" : base_path end - # Helper for community user profile URLs (for navigation links) def community_user_url(user) if Rails.env.production? "https://#{Rails.application.config.x.domains.community}/#{user.to_param}" @@ -389,8 +361,6 @@ def community_user_url(user) end end - # Helper for community user path with query params (for sorting/filtering links) - # On community domain in production, uses /:id. Otherwise uses /community/:id. def community_user_path(user, params = {}) base_path = if Rails.env.production? && request.host == Rails.application.config.x.domains.community "/#{user.to_param}" @@ -404,7 +374,6 @@ def community_user_path(user, params = {}) query.present? ? "#{base_path}?#{query}" : base_path end - # URL for community map data endpoint (works across domains) def community_map_data_url if Rails.env.production? && request.host == Rails.application.config.x.domains.community "/map_data" @@ -413,8 +382,6 @@ def community_map_data_url end end - # Generate a full URL on the primary domain (whyruby.info) for a given path. - # Used for footer legal links that must resolve on both domains. def main_site_url(path) if Rails.env.production? && request.host == Rails.application.config.x.domains.community "https://#{Rails.application.config.x.domains.primary}#{path}" @@ -423,9 +390,6 @@ def main_site_url(path) end end - # Canonical URL for community root (for meta tags) - # Production: https://rubycommunity.org/ - # Development: http://localhost:3003/community def community_root_canonical_url if Rails.env.production? "https://#{Rails.application.config.x.domains.community}/" @@ -434,9 +398,6 @@ def community_root_canonical_url end end - # Canonical URL for community user profile (for meta tags) - # Production: https://rubycommunity.org/username - # Development: http://localhost:3003/community/username def community_user_canonical_url(user) if Rails.env.production? "https://#{Rails.application.config.x.domains.community}/#{user.to_param}" diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb new file mode 100644 index 0000000..23de56a --- /dev/null +++ b/app/helpers/home_helper.rb @@ -0,0 +1,2 @@ +module HomeHelper +end diff --git a/app/helpers/madmin/application_helper.rb b/app/helpers/madmin/application_helper.rb new file mode 100644 index 0000000..8edb600 --- /dev/null +++ b/app/helpers/madmin/application_helper.rb @@ -0,0 +1,104 @@ +module Madmin + module ApplicationHelper + include Pagy::Frontend if defined?(Pagy::Frontend) + + # Navigation link helper for Madmin sidebar + def madmin_nav_link(path, icon, label, nested: false) + is_active = current_page?(path) || (path != "/madmin" && request.path.start_with?(path.to_s.split("?").first)) + + base_classes = "flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors" + active_classes = "bg-dark-800 text-white" + inactive_classes = "text-dark-400 hover:text-dark-100 hover:bg-dark-800" + nested_classes = nested ? "pl-10" : "" + + link_to path, class: "#{base_classes} #{is_active ? active_classes : inactive_classes} #{nested_classes}" do + inline_svg("icons/#{icon}.svg", class: "w-5 h-5 flex-shrink-0") + content_tag(:span, label) + end + end + + # Collapsible navigation group for Madmin sidebar + def madmin_nav_group(id, icon, label, &block) + content = capture(&block) + + content_tag(:div, class: "space-y-1") do + button = content_tag(:button, + type: "button", + data: { action: "click->sidebar#toggleGroup", group_id: id }, + class: "w-full flex items-center justify-between gap-3 px-3 py-2 text-sm text-dark-400 hover:text-dark-100 hover:bg-dark-800 rounded-lg transition-colors") do + icon_and_label = content_tag(:div, class: "flex items-center gap-3") do + inline_svg("icons/#{icon}.svg", class: "w-5 h-5 flex-shrink-0") + content_tag(:span, label) + end + chevron = inline_svg("icons/chevron-right.svg", class: "w-4 h-4 transition-transform duration-200 rotate-90", data: { chevron: true }) + icon_and_label + chevron + end + + group_content = content_tag(:div, content, id: "nav-group-#{id}", class: "space-y-1") + button + group_content + end + end + + def mask_secret(value) + return nil if value.blank? + + "***#{value.last(4)}" + end + + # Sidebar entity counts, memoized per request + def madmin_sidebar_counts + @madmin_sidebar_counts ||= Rails.cache.fetch("madmin_sidebar_counts", expires_in: 2.minutes) do + { + posts: Post.unscoped.count, + categories: Category.count, + tags: Tag.count, + comments: Comment.count, + reports: Report.count, + testimonials: Testimonial.count, + users: User.count, + teams: Team.count, + projects: Project.count + } + end + end + + class MarkdownRenderer < Redcarpet::Render::HTML + include Rouge::Plugins::Redcarpet + + def block_code(code, language) + language ||= "text" + formatter = Rouge::Formatters::HTMLLegacy.new(css_class: "highlight") + lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new + formatter.format(lexer.lex(code)) + end + end + + def markdown(text) + return "" if text.blank? + + options = { + filter_html: true, + hard_wrap: true, + link_attributes: { rel: "nofollow", target: "_blank" }, + fenced_code_blocks: true, + prettify: true, + tables: true, + with_toc_data: true, + no_intra_emphasis: true + } + + extensions = { + autolink: true, + superscript: true, + disable_indented_code_blocks: true, + fenced_code_blocks: true, + tables: true, + strikethrough: true, + highlight: true + } + + renderer = MarkdownRenderer.new(options) + markdown_parser = Redcarpet::Markdown.new(renderer, extensions) + + markdown_parser.render(text).html_safe + end + end +end diff --git a/app/helpers/madmin/settings_helper.rb b/app/helpers/madmin/settings_helper.rb new file mode 100644 index 0000000..d64073e --- /dev/null +++ b/app/helpers/madmin/settings_helper.rb @@ -0,0 +1,4 @@ +module Madmin + module SettingsHelper + end +end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb new file mode 100644 index 0000000..309f8b2 --- /dev/null +++ b/app/helpers/sessions_helper.rb @@ -0,0 +1,2 @@ +module SessionsHelper +end diff --git a/app/javascript/controllers/auto_width_select_controller.js b/app/javascript/controllers/auto_width_select_controller.js new file mode 100644 index 0000000..18ba5e0 --- /dev/null +++ b/app/javascript/controllers/auto_width_select_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.resize() + } + + resize() { + const tempSpan = document.createElement("span") + tempSpan.style.visibility = "hidden" + tempSpan.style.position = "absolute" + tempSpan.style.whiteSpace = "nowrap" + tempSpan.style.font = window.getComputedStyle(this.element).font + tempSpan.textContent = this.element.options[this.element.selectedIndex]?.text || "" + document.body.appendChild(tempSpan) + + const textWidth = tempSpan.offsetWidth + document.body.removeChild(tempSpan) + + // Add padding for the chevron icon and some buffer + this.element.style.width = `${textWidth + 32}px` + } +} diff --git a/app/javascript/controllers/chat_input_controller.js b/app/javascript/controllers/chat_input_controller.js new file mode 100644 index 0000000..01d3504 --- /dev/null +++ b/app/javascript/controllers/chat_input_controller.js @@ -0,0 +1,126 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["textarea", "form", "fileInput", "attachButton", "previewArea", "inputContainer"] + + connect() { + this.resize() + this.selectedFiles = [] + this.textareaTarget.focus() + } + + resize() { + const textarea = this.textareaTarget + textarea.style.height = "auto" + textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" + } + + submit(event) { + if (event.key === "Enter") { + if (event.altKey) { + // Alt/Option+Enter inserts a new line + event.preventDefault() + const textarea = this.textareaTarget + const start = textarea.selectionStart + const end = textarea.selectionEnd + const value = textarea.value + textarea.value = value.substring(0, start) + "\n" + value.substring(end) + textarea.selectionStart = textarea.selectionEnd = start + 1 + this.resize() + } else { + // Enter submits the form + event.preventDefault() + if (this.textareaTarget.value.trim() || this.selectedFiles.length > 0) { + this.formTarget.requestSubmit() + this.scrollToBottom() + } + } + } + } + + triggerFileInput() { + if (this.hasFileInputTarget) { + this.fileInputTarget.click() + } + } + + handleFileSelect(event) { + const newFiles = Array.from(event.target.files) + if (newFiles.length > 0) { + this.selectedFiles = [...this.selectedFiles, ...newFiles] + this.syncFileInput() + this.showAttachmentPreview() + } + } + + syncFileInput() { + if (this.hasFileInputTarget) { + const dt = new DataTransfer() + this.selectedFiles.forEach(file => dt.items.add(file)) + this.fileInputTarget.files = dt.files + } + } + + showAttachmentPreview() { + if (!this.hasPreviewAreaTarget) return + + this.previewAreaTarget.classList.remove("hidden") + this.previewAreaTarget.replaceChildren() + + this.selectedFiles.forEach((file, index) => { + const preview = document.createElement("div") + preview.className = "relative group w-16 h-16" + + if (file.type.startsWith("image/")) { + const img = document.createElement("img") + img.className = "w-16 h-16 object-cover rounded-lg" + img.src = URL.createObjectURL(file) + img.onload = () => URL.revokeObjectURL(img.src) + preview.appendChild(img) + } else { + const container = document.createElement("div") + container.className = "w-16 h-16 bg-dark-700 rounded-lg flex flex-col items-center justify-center" + const ext = file.name.split('.').pop()?.toUpperCase() || 'FILE' + const icon = document.createElement("span") + icon.className = "text-lg" + icon.textContent = file.type === "application/pdf" ? "📕" : "📄" + const label = document.createElement("span") + label.className = "text-[9px] text-dark-400 truncate w-full text-center px-1" + label.textContent = ext + container.appendChild(icon) + container.appendChild(label) + preview.appendChild(container) + } + + const removeBtn = document.createElement("button") + removeBtn.type = "button" + removeBtn.className = "absolute -top-1 -right-1 w-5 h-5 bg-dark-600 hover:bg-dark-500 text-dark-200 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity" + removeBtn.dataset.index = index + removeBtn.dataset.action = "click->chat-input#removeFile" + removeBtn.textContent = "×" + preview.appendChild(removeBtn) + + this.previewAreaTarget.appendChild(preview) + }) + } + + scrollToBottom() { + const scrollContainer = document.querySelector('[data-controller="scroll-bottom"]') + if (scrollContainer) { + scrollContainer.scrollTop = scrollContainer.scrollHeight + } + } + + removeFile(event) { + const index = parseInt(event.target.dataset.index) + this.selectedFiles.splice(index, 1) + this.syncFileInput() + + if (this.selectedFiles.length === 0 && this.hasPreviewAreaTarget) { + this.previewAreaTarget.classList.add("hidden") + this.previewAreaTarget.replaceChildren() + } else { + this.showAttachmentPreview() + } + } +} diff --git a/app/javascript/controllers/image_upload_controller.js b/app/javascript/controllers/image_upload_controller.js new file mode 100644 index 0000000..54c81f2 --- /dev/null +++ b/app/javascript/controllers/image_upload_controller.js @@ -0,0 +1,94 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "dropzone", "placeholder", "preview", "previewImage", "removeFlag", "spinner"] + + connect() { + this.form = this.element.closest("form") + if (this.form) { + this.submitStart = () => this.showSpinner() + this.submitEnd = () => this.hideSpinner() + this.form.addEventListener("turbo:submit-start", this.submitStart) + this.form.addEventListener("turbo:submit-end", this.submitEnd) + } + } + + disconnect() { + if (this.form) { + this.form.removeEventListener("turbo:submit-start", this.submitStart) + this.form.removeEventListener("turbo:submit-end", this.submitEnd) + } + } + + showSpinner() { + if (this.hasSpinnerTarget && this.inputTarget.files.length > 0) { + this.spinnerTarget.classList.remove("hidden") + } + } + + hideSpinner() { + if (this.hasSpinnerTarget) { + this.spinnerTarget.classList.add("hidden") + } + } + + browse(event) { + if (event.target.closest("button[data-action*='clear']")) return + this.inputTarget.click() + } + + dragover(event) { + event.preventDefault() + this.dropzoneTarget.classList.add("border-accent-500") + } + + dragleave() { + this.dropzoneTarget.classList.remove("border-accent-500") + } + + drop(event) { + event.preventDefault() + this.dropzoneTarget.classList.remove("border-accent-500") + + const file = event.dataTransfer.files[0] + if (file && file.type.startsWith("image/")) { + this.setFile(file) + } + } + + inputTargetConnected() { + this.inputTarget.addEventListener("change", () => { + const file = this.inputTarget.files[0] + if (file) this.showPreview(file) + }) + } + + setFile(file) { + const dt = new DataTransfer() + dt.items.add(file) + this.inputTarget.files = dt.files + this.inputTarget.dispatchEvent(new Event("change", { bubbles: true })) + this.showPreview(file) + } + + showPreview(file) { + const reader = new FileReader() + reader.onload = (e) => { + this.previewImageTarget.src = e.target.result + this.placeholderTarget.classList.add("hidden") + this.previewTarget.classList.remove("hidden") + if (this.hasRemoveFlagTarget) this.removeFlagTarget.value = "0" + } + reader.readAsDataURL(file) + } + + clear(event) { + event.stopPropagation() + this.inputTarget.value = "" + this.previewTarget.classList.add("hidden") + this.placeholderTarget.classList.remove("hidden") + this.previewImageTarget.src = "" + if (this.hasRemoveFlagTarget) this.removeFlagTarget.value = "1" + this.inputTarget.dispatchEvent(new Event("change", { bubbles: true })) + } +} diff --git a/app/javascript/controllers/lightbox_controller.js b/app/javascript/controllers/lightbox_controller.js new file mode 100644 index 0000000..5e2c45f --- /dev/null +++ b/app/javascript/controllers/lightbox_controller.js @@ -0,0 +1,114 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + url: String, + filename: String, + type: String + } + + open(event) { + event.preventDefault() + event.stopPropagation() + + const overlay = document.createElement("div") + overlay.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/90" + overlay.addEventListener("click", (e) => { + if (e.target === overlay) this.close() + }) + + const container = document.createElement("div") + container.className = "relative max-w-[90vw] max-h-[90vh] flex flex-col" + container.addEventListener("click", (e) => e.stopPropagation()) + + if (this.typeValue === "image") { + const img = document.createElement("img") + img.src = this.urlValue + img.className = "max-w-full max-h-[calc(90vh-60px)] object-contain rounded-lg" + img.alt = this.filenameValue + container.appendChild(img) + } else { + const preview = document.createElement("div") + preview.className = "bg-dark-800 rounded-lg p-8 flex flex-col items-center gap-4 min-w-[300px]" + const icon = document.createElement("span") + icon.className = "text-6xl" + icon.textContent = this.typeValue === "pdf" || this.filenameValue.endsWith(".pdf") ? "📕" : "📄" + const name = document.createElement("span") + name.className = "text-dark-200 text-lg font-medium text-center" + name.textContent = this.filenameValue + preview.appendChild(icon) + preview.appendChild(name) + container.appendChild(preview) + } + + const toolbar = document.createElement("div") + toolbar.className = "flex items-center justify-center gap-4 mt-4" + + const downloadBtn = document.createElement("a") + downloadBtn.href = this.urlValue + downloadBtn.download = this.filenameValue + downloadBtn.className = "flex items-center gap-2 bg-dark-700 hover:bg-dark-600 text-white px-4 py-2 rounded-lg transition-colors" + const downloadIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg") + downloadIcon.setAttribute("class", "w-5 h-5") + downloadIcon.setAttribute("fill", "none") + downloadIcon.setAttribute("stroke", "currentColor") + downloadIcon.setAttribute("viewBox", "0 0 24 24") + const downloadPath = document.createElementNS("http://www.w3.org/2000/svg", "path") + downloadPath.setAttribute("stroke-linecap", "round") + downloadPath.setAttribute("stroke-linejoin", "round") + downloadPath.setAttribute("stroke-width", "2") + downloadPath.setAttribute("d", "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4") + downloadIcon.appendChild(downloadPath) + downloadBtn.appendChild(downloadIcon) + downloadBtn.appendChild(document.createTextNode(" Download")) + toolbar.appendChild(downloadBtn) + + const closeBtn = document.createElement("button") + closeBtn.type = "button" + closeBtn.className = "flex items-center gap-2 bg-dark-700 hover:bg-dark-600 text-white px-4 py-2 rounded-lg transition-colors" + const closeIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg") + closeIcon.setAttribute("class", "w-5 h-5") + closeIcon.setAttribute("fill", "none") + closeIcon.setAttribute("stroke", "currentColor") + closeIcon.setAttribute("viewBox", "0 0 24 24") + const closePath = document.createElementNS("http://www.w3.org/2000/svg", "path") + closePath.setAttribute("stroke-linecap", "round") + closePath.setAttribute("stroke-linejoin", "round") + closePath.setAttribute("stroke-width", "2") + closePath.setAttribute("d", "M6 18L18 6M6 6l12 12") + closeIcon.appendChild(closePath) + closeBtn.appendChild(closeIcon) + closeBtn.appendChild(document.createTextNode(" Close")) + closeBtn.addEventListener("click", (e) => { + e.preventDefault() + e.stopPropagation() + this.close() + }) + toolbar.appendChild(closeBtn) + + container.appendChild(toolbar) + overlay.appendChild(container) + document.body.appendChild(overlay) + this.overlay = overlay + + document.addEventListener("keydown", this.handleKeydown) + } + + handleKeydown = (event) => { + if (event.key === "Escape") { + this.close() + } + } + + close() { + if (this.overlay) { + this.overlay.remove() + this.overlay = null + document.removeEventListener("keydown", this.handleKeydown) + } + } + + disconnect() { + this.close() + } +} diff --git a/app/javascript/controllers/link_metadata_controller.js b/app/javascript/controllers/link_metadata_controller.js index 087eef1..b896b83 100644 --- a/app/javascript/controllers/link_metadata_controller.js +++ b/app/javascript/controllers/link_metadata_controller.js @@ -27,7 +27,7 @@ export default class extends Controller { const postId = form ? (form.dataset.postId || form.getAttribute('data-post-id')) : null try { - const response = await fetch('/posts/fetch_metadata', { + const response = await fetch('/posts/metadata', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -170,7 +170,7 @@ export default class extends Controller { const postId = form ? (form.dataset.postId || form.getAttribute('data-post-id')) : null try { - const response = await fetch('/posts/check_duplicate_url', { + const response = await fetch('/posts/duplicate_check', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/app/javascript/controllers/model_select_controller.js b/app/javascript/controllers/model_select_controller.js new file mode 100644 index 0000000..06a2977 --- /dev/null +++ b/app/javascript/controllers/model_select_controller.js @@ -0,0 +1,45 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["button", "menu", "input", "label"] + + connect() { + this.closeOnClickOutside = this.closeOnClickOutside.bind(this) + } + + toggle() { + if (this.menuTarget.classList.contains("hidden")) { + this.open() + } else { + this.close() + } + } + + open() { + this.menuTarget.classList.remove("hidden") + document.addEventListener("click", this.closeOnClickOutside) + } + + close() { + this.menuTarget.classList.add("hidden") + document.removeEventListener("click", this.closeOnClickOutside) + } + + closeOnClickOutside(event) { + if (!this.element.contains(event.target)) { + this.close() + } + } + + select(event) { + const value = event.currentTarget.dataset.value + const label = event.currentTarget.dataset.label + this.inputTarget.value = value + this.labelTarget.textContent = label + this.close() + } + + disconnect() { + document.removeEventListener("click", this.closeOnClickOutside) + } +} diff --git a/app/javascript/controllers/name_check_controller.js b/app/javascript/controllers/name_check_controller.js new file mode 100644 index 0000000..785d709 --- /dev/null +++ b/app/javascript/controllers/name_check_controller.js @@ -0,0 +1,90 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "available", "taken", "submit"] + static values = { + url: String, + original: String + } + + connect() { + this.timeout = null + } + + disconnect() { + if (this.timeout) clearTimeout(this.timeout) + } + + check() { + if (this.timeout) clearTimeout(this.timeout) + + const name = this.inputTarget.value.trim() + + if (!name) { + this.hideStatus() + this.disableSubmit() + return + } + + if (name === this.originalValue) { + this.hideStatus() + this.enableSubmit() + return + } + + this.timeout = setTimeout(() => this.fetchAvailability(name), 300) + } + + async fetchAvailability(name) { + try { + const url = new URL(this.urlValue, window.location.origin) + url.searchParams.set("name", name) + + const response = await fetch(url, { + headers: { "Accept": "application/json" } + }) + const data = await response.json() + + if (this.inputTarget.value.trim() !== name) return + + if (data.available) { + this.showAvailable() + this.enableSubmit() + } else { + this.showTaken() + this.disableSubmit() + } + } catch { + this.hideStatus() + this.enableSubmit() + } + } + + showAvailable() { + this.availableTarget.classList.remove("hidden") + this.takenTarget.classList.add("hidden") + this.inputTarget.classList.remove("border-red-500", "focus:border-red-500") + this.inputTarget.classList.add("border-green-500", "focus:border-green-500") + } + + showTaken() { + this.takenTarget.classList.remove("hidden") + this.availableTarget.classList.add("hidden") + this.inputTarget.classList.remove("border-green-500", "focus:border-green-500") + this.inputTarget.classList.add("border-red-500", "focus:border-red-500") + } + + hideStatus() { + this.availableTarget.classList.add("hidden") + this.takenTarget.classList.add("hidden") + this.inputTarget.classList.remove("border-green-500", "focus:border-green-500", "border-red-500", "focus:border-red-500") + } + + enableSubmit() { + if (this.hasSubmitTarget) this.submitTarget.disabled = false + } + + disableSubmit() { + if (this.hasSubmitTarget) this.submitTarget.disabled = true + } +} diff --git a/app/javascript/controllers/pricing_toggle_controller.js b/app/javascript/controllers/pricing_toggle_controller.js new file mode 100644 index 0000000..3a1c633 --- /dev/null +++ b/app/javascript/controllers/pricing_toggle_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["monthlyButton", "yearlyButton", "monthlyPrices", "yearlyPrices"] + + connect() { + this.showMonthly() + } + + showMonthly() { + this.monthlyPricesTarget.classList.remove("hidden") + this.yearlyPricesTarget.classList.add("hidden") + this.monthlyButtonTarget.classList.add("bg-accent-600", "text-white") + this.monthlyButtonTarget.classList.remove("text-dark-400") + this.yearlyButtonTarget.classList.remove("bg-accent-600", "text-white") + this.yearlyButtonTarget.classList.add("text-dark-400") + } + + showYearly() { + this.yearlyPricesTarget.classList.remove("hidden") + this.monthlyPricesTarget.classList.add("hidden") + this.yearlyButtonTarget.classList.add("bg-accent-600", "text-white") + this.yearlyButtonTarget.classList.remove("text-dark-400") + this.monthlyButtonTarget.classList.remove("bg-accent-600", "text-white") + this.monthlyButtonTarget.classList.add("text-dark-400") + } +} diff --git a/app/javascript/controllers/scroll_bottom_controller.js b/app/javascript/controllers/scroll_bottom_controller.js new file mode 100644 index 0000000..a1ecb08 --- /dev/null +++ b/app/javascript/controllers/scroll_bottom_controller.js @@ -0,0 +1,40 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.scrollToBottom() + this.observeNewMessages() + this.observeImageLoads() + } + + disconnect() { + this.observer?.disconnect() + } + + observeNewMessages() { + this.observer = new MutationObserver(() => { + this.scrollToBottom() + this.observeImageLoads() + }) + + this.observer.observe(this.element, { + childList: true, + subtree: true + }) + } + + observeImageLoads() { + this.element.querySelectorAll("img:not([data-scroll-observed])").forEach(img => { + img.dataset.scrollObserved = "true" + if (!img.complete) { + img.addEventListener("load", () => this.scrollToBottom(), { once: true }) + } + }) + } + + scrollToBottom() { + requestAnimationFrame(() => { + this.element.scrollTop = this.element.scrollHeight + }) + } +} diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js new file mode 100644 index 0000000..eda8fad --- /dev/null +++ b/app/javascript/controllers/sidebar_controller.js @@ -0,0 +1,212 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["sidebar", "overlay", "content", "navGroup", "label", "collapseIcon", "mainContent"] + static values = { + open: { type: Boolean, default: false }, + collapsed: { type: Array, default: [] }, + minimized: { type: Boolean, default: false } + } + + connect() { + this.loadCollapsedState() + this.loadMinimizedState() + this.checkMobileView() + window.addEventListener("resize", this.handleResize.bind(this)) + } + + disconnect() { + window.removeEventListener("resize", this.handleResize.bind(this)) + } + + toggle() { + this.openValue = !this.openValue + this.updateSidebarVisibility() + } + + open() { + this.openValue = true + this.updateSidebarVisibility() + } + + close() { + this.openValue = false + this.updateSidebarVisibility() + } + + toggleMinimize() { + this.minimizedValue = !this.minimizedValue + this.updateMinimizedState() + this.saveMinimizedState() + } + + loadMinimizedState() { + try { + const saved = localStorage.getItem("sidebar-minimized") + if (saved !== null) { + this.minimizedValue = JSON.parse(saved) + this.updateMinimizedState() + } + } catch (e) { + console.warn("Could not load minimized state:", e) + } + } + + saveMinimizedState() { + try { + localStorage.setItem("sidebar-minimized", JSON.stringify(this.minimizedValue)) + } catch (e) { + console.warn("Could not save minimized state:", e) + } + } + + updateMinimizedState() { + if (!this.hasSidebarTarget) return + + if (this.minimizedValue) { + this.sidebarTarget.classList.add("sidebar-collapsed") + this.sidebarTarget.style.setProperty("--sidebar-width", "4rem") + this.forceReflow(this.sidebarTarget) + this.labelTargets.forEach(el => { + el.classList.add("opacity-0", "w-0", "overflow-hidden") + el.classList.remove("opacity-100") + }) + this.collapseIconTargets.forEach(el => { + el.classList.add("rotate-180") + }) + if (this.hasMainContentTarget) { + this.mainContentTarget.classList.add("sidebar-collapsed") + this.forceReflow(this.mainContentTarget) + } + } else { + this.sidebarTarget.classList.remove("sidebar-collapsed") + this.sidebarTarget.style.setProperty("--sidebar-width", "16rem") + this.forceReflow(this.sidebarTarget) + this.labelTargets.forEach(el => { + el.classList.remove("opacity-0", "w-0", "overflow-hidden") + el.classList.add("opacity-100") + }) + this.collapseIconTargets.forEach(el => { + el.classList.remove("rotate-180") + }) + if (this.hasMainContentTarget) { + this.mainContentTarget.classList.remove("sidebar-collapsed") + this.forceReflow(this.mainContentTarget) + } + } + } + + forceReflow(element) { + const display = element.style.display + element.style.display = "none" + void element.offsetHeight + element.style.display = display || "" + } + + updateSidebarVisibility() { + if (this.hasSidebarTarget) { + if (this.openValue) { + this.sidebarTarget.classList.remove("-translate-x-full") + this.sidebarTarget.classList.add("translate-x-0") + } else { + this.sidebarTarget.classList.add("-translate-x-full") + this.sidebarTarget.classList.remove("translate-x-0") + } + } + + if (this.hasOverlayTarget) { + if (this.openValue) { + this.overlayTarget.classList.remove("hidden") + this.overlayTarget.classList.add("block") + } else { + this.overlayTarget.classList.add("hidden") + this.overlayTarget.classList.remove("block") + } + } + } + + toggleGroup(event) { + const groupId = event.currentTarget.dataset.groupId + const groupContent = document.getElementById(`nav-group-${groupId}`) + const chevron = event.currentTarget.querySelector("[data-chevron]") + + if (!groupContent) return + + const isCollapsed = groupContent.classList.contains("hidden") + + if (isCollapsed) { + groupContent.classList.remove("hidden") + chevron?.classList.add("rotate-90") + this.removeFromCollapsed(groupId) + } else { + groupContent.classList.add("hidden") + chevron?.classList.remove("rotate-90") + this.addToCollapsed(groupId) + } + + this.saveCollapsedState() + } + + loadCollapsedState() { + try { + const saved = localStorage.getItem("sidebar-collapsed-groups") + if (saved) { + this.collapsedValue = JSON.parse(saved) + this.applyCollapsedState() + } + } catch (e) { + console.warn("Could not load sidebar state:", e) + } + } + + saveCollapsedState() { + try { + localStorage.setItem("sidebar-collapsed-groups", JSON.stringify(this.collapsedValue)) + } catch (e) { + console.warn("Could not save sidebar state:", e) + } + } + + applyCollapsedState() { + this.collapsedValue.forEach(groupId => { + const groupContent = document.getElementById(`nav-group-${groupId}`) + const trigger = document.querySelector(`[data-group-id="${groupId}"]`) + const chevron = trigger?.querySelector("[data-chevron]") + + if (groupContent) { + groupContent.classList.add("hidden") + } + if (chevron) { + chevron.classList.remove("rotate-90") + } + }) + } + + addToCollapsed(groupId) { + if (!this.collapsedValue.includes(groupId)) { + this.collapsedValue = [...this.collapsedValue, groupId] + } + } + + removeFromCollapsed(groupId) { + this.collapsedValue = this.collapsedValue.filter(id => id !== groupId) + } + + handleResize() { + this.checkMobileView() + } + + checkMobileView() { + const isMobile = window.innerWidth < 1024 + if (!isMobile && this.hasSidebarTarget) { + this.sidebarTarget.classList.remove("-translate-x-full") + this.sidebarTarget.classList.add("translate-x-0") + if (this.hasOverlayTarget) { + this.overlayTarget.classList.add("hidden") + } + } else if (isMobile && !this.openValue && this.hasSidebarTarget) { + this.sidebarTarget.classList.add("-translate-x-full") + this.sidebarTarget.classList.remove("translate-x-0") + } + } +} diff --git a/app/javascript/controllers/typing_indicator_controller.js b/app/javascript/controllers/typing_indicator_controller.js new file mode 100644 index 0000000..f8a8430 --- /dev/null +++ b/app/javascript/controllers/typing_indicator_controller.js @@ -0,0 +1,24 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["dots"] + + connect() { + this.observer = new MutationObserver(() => { + if (this.hasDots && this.element.childNodes.length > 1) { + this.dotsTarget.remove() + this.observer.disconnect() + } + }) + + this.observer.observe(this.element, { childList: true }) + } + + get hasDots() { + return this.hasDotsTarget + } + + disconnect() { + this.observer?.disconnect() + } +} diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index d394c3d..a6bbc43 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -4,4 +4,9 @@ class ApplicationJob < ActiveJob::Base # Most jobs are safe to ignore if the underlying records are no longer available # discard_on ActiveJob::DeserializationError + + # Ensure RubyLLM has fresh credentials from DB before each job. + # Config lives in process memory, so worker processes won't see + # credentials saved by the web process after boot without this. + before_perform { ProviderCredential.configure_ruby_llm! } end diff --git a/app/jobs/backfill_translations_job.rb b/app/jobs/backfill_translations_job.rb new file mode 100644 index 0000000..a547a56 --- /dev/null +++ b/app/jobs/backfill_translations_job.rb @@ -0,0 +1,23 @@ +class BackfillTranslationsJob < ApplicationJob + def perform(team_id, target_locale) + team = Team.find_by(id: team_id) + return unless team + + translatable_models.each do |model_class| + records = model_class.where(team_id: team.id) + next if records.empty? + + jobs = records.map do |record| + TranslateContentJob.new(model_class.name, record.id, I18n.default_locale.to_s, target_locale) + end + + ActiveJob.perform_all_later(jobs) + end + end + + private + + def translatable_models + Translatable.registry.select { |klass| klass.column_names.include?("team_id") } + end +end diff --git a/app/jobs/chat_response_job.rb b/app/jobs/chat_response_job.rb new file mode 100644 index 0000000..6e18e7c --- /dev/null +++ b/app/jobs/chat_response_job.rb @@ -0,0 +1,33 @@ +class ChatResponseJob < ApplicationJob + def perform(chat_id, content, attachment_paths = []) + chat = Chat.find(chat_id) + + # Build the ask options + ask_options = {} + ask_options[:with] = attachment_paths if attachment_paths.present? + + chat.ask(content, **ask_options) do |chunk| + if chunk.content && !chunk.content.blank? + message = chat.messages.last + message.broadcast_append_chunk(chunk.content) + end + end + ensure + # Clean up temporary files after processing + cleanup_temp_files(attachment_paths) if attachment_paths.present? + end + + private + + def cleanup_temp_files(paths) + paths.each do |path| + next unless File.exist?(path) + File.delete(path) + # Also remove the parent directory if empty + parent_dir = File.dirname(path) + FileUtils.rmdir(parent_dir) if Dir.empty?(parent_dir) + rescue StandardError => e + Rails.logger.warn "Failed to clean up temp file #{path}: #{e.message}" + end + end +end diff --git a/app/jobs/generate_success_story_image_job.rb b/app/jobs/generate_success_story_image_job.rb index 08a3ed6..7285d73 100644 --- a/app/jobs/generate_success_story_image_job.rb +++ b/app/jobs/generate_success_story_image_job.rb @@ -2,29 +2,7 @@ class GenerateSuccessStoryImageJob < ApplicationJob queue_as :default def perform(post, force: false) - Rails.logger.info "GenerateSuccessStoryImageJob started for post #{post.id}: force=#{force}, has_image=#{post.featured_image.attached?}" - - # Only process success stories with logos - return unless post.success_story? && post.logo_svg.present? - - # Skip if already has a generated image (unless forced) - should_regenerate = force || !post.featured_image.attached? - - unless should_regenerate - Rails.logger.info "Skipping image generation for post #{post.id} - image already exists (force=#{force})" - return - end - - # Purge existing image if we're regenerating - if force && post.featured_image.attached? - Rails.logger.info "Purging existing image for post #{post.id} before regeneration" - post.featured_image.purge - end - - # Generate the image - SuccessStoryImageGenerator.new(post).generate! - - Rails.logger.info "Generated success story image for post #{post.id}" + post.generate_og_image!(force: force) rescue => e Rails.logger.error "Failed to generate success story image for post #{post.id}: #{e.message}" Rails.logger.error e.backtrace.join("\n") diff --git a/app/jobs/generate_summary_job.rb b/app/jobs/generate_summary_job.rb index 19c855f..0462cd4 100644 --- a/app/jobs/generate_summary_job.rb +++ b/app/jobs/generate_summary_job.rb @@ -2,250 +2,6 @@ class GenerateSummaryJob < ApplicationJob queue_as :default def perform(post, force: false) - # Skip if summary exists and we're not forcing regeneration - return if post.summary.present? && !force - - # Prepare the text and context for summarization - text_to_summarize, context = prepare_text_with_context(post) - - # Skip if we couldn't get meaningful text - if text_to_summarize.blank? || text_to_summarize.length < 50 - Rails.logger.warn "Insufficient content for summary generation for post #{post.id}" - return - end - - # Try different AI providers - summary = nil - error = nil - - # Try Anthropic first if available - if anthropic_configured? - summary = generate_with_anthropic(text_to_summarize, context) - end - - # Fall back to OpenAI if Anthropic fails or is not configured - if summary.blank? && openai_configured? - summary = generate_with_openai(text_to_summarize, context) - end - - if summary.present? - # Clean up any meta-language and enforce length - summary = clean_summary(summary) - post.update!(summary: summary) - broadcast_update(post) - else - Rails.logger.error "Failed to generate summary for post #{post.id}: No AI service available or all failed" - end - end - - private - - def prepare_text_with_context(post) - if post.link? - # For external links, fetch the actual content - text = fetch_external_content(post.url) - - # If fetching failed, try to at least use title - if text.blank? - text = "Title: #{post.title}\nURL: #{post.url}" - end - - context = { - type: "external_link", - title: post.title, - url: post.url, - domain: (URI.parse(post.url).host rescue nil) - } - else - # For articles and success stories, use the actual content - text = post.content - # Remove markdown formatting for cleaner summaries - text = ActionView::Base.full_sanitizer.sanitize(text) - context = { - type: post.post_type, - title: post.title, - has_code: post.content.include?("```") - } - end - - # Truncate to reasonable length - text = text.to_s.truncate(6000) - - [ text, context ] - end - - def fetch_external_content(url) - begin - Rails.logger.info "Fetching content from: #{url}" - - fetcher = MetadataFetcher.new(url, - connection_timeout: 5, - read_timeout: 5, - retries: 1, - allow_redirections: :safe - ) - - result = fetcher.fetch! - return nil if result.blank? || result[:parsed].blank? - - # Try to get the main content - content_parts = [] - - # Add title - content_parts << "Title: #{result[:title]}" if result[:title].present? - - # Add description - content_parts << "Description: #{result[:description]}" if result[:description].present? - - # Get the main text content - if result[:parsed].present? - # Try to extract main content, removing navigation, ads, etc. - main_content = extract_main_content(result[:parsed]) - content_parts << main_content if main_content.present? - end - - # Fallback to meta description and raw text if needed - if content_parts.length <= 2 - raw_text = result[:parsed].css("body").text.squish rescue nil - content_parts << raw_text if raw_text.present? - end - - content_parts.join("\n\n") - rescue => e - Rails.logger.error "Failed to fetch external content from #{url}: #{e.message}" - nil - end - end - - def extract_main_content(parsed_doc) - # Try common content selectors - content_selectors = [ - "main", "article", '[role="main"]', ".content", "#content", - ".post-content", ".entry-content", ".article-body" - ] - - content_selectors.each do |selector| - element = parsed_doc.at_css(selector) - if element - text = element.text.squish - return text if text.length > 100 - end - end - - # If no main content found, try paragraphs - paragraphs = parsed_doc.css("p").map(&:text).reject(&:blank?) - return paragraphs.join(" ") if paragraphs.any? - - nil - end - - def anthropic_configured? - Rails.application.credentials.dig(:anthropic, :api_key).present? || - Rails.application.credentials.dig(:anthropic, :access_token).present? - end - - def openai_configured? - Rails.application.credentials.dig(:openai, :api_key).present? || - Rails.application.credentials.dig(:openai, :access_token).present? - end - - def generate_with_anthropic(text, context) - api_key = Rails.application.credentials.dig(:anthropic, :api_key).presence || - Rails.application.credentials.dig(:anthropic, :access_token) - - begin - client = Anthropic::Client.new( - api_key: api_key - ) - - system_prompt = build_system_prompt(context) - user_prompt = build_user_prompt(text, context) - - response = client.messages( - parameters: { - model: "claude-3-haiku-20240307", - max_tokens: 50, - temperature: 0.3, - system: system_prompt, - messages: [ - { - role: "user", - content: user_prompt - } - ] - } - ) - - response.dig("content", 0, "text") - rescue => e - Rails.logger.error "Anthropic API error: #{e.message}" - nil - end - end - - def generate_with_openai(text, context) - token = Rails.application.credentials.dig(:openai, :api_key).presence || - Rails.application.credentials.dig(:openai, :access_token) - - begin - client = OpenAI::Client.new( - access_token: token - ) - - system_prompt = build_system_prompt(context) - user_prompt = build_user_prompt(text, context) - - response = client.chat( - parameters: { - model: "gpt-3.5-turbo", - messages: [ - { - role: "system", - content: system_prompt - }, - { - role: "user", - content: user_prompt - } - ], - temperature: 0.3, - max_tokens: 50 - } - ) - - response.dig("choices", 0, "message", "content") - rescue => e - Rails.logger.error "OpenAI API error: #{e.message}" - nil - end - end - - def build_system_prompt(context) - "Output ONLY a single teaser sentence. No preamble. Maximum 200 characters. Hook the reader with the most intriguing aspect." - end - - def build_user_prompt(text, context) - "Teaser:\n\n#{text}" - end - - def clean_summary(summary) - # Remove common meta-language prefixes - cleaned = summary.gsub(/^(Here is a |Here's a |Here are |Teaser: |The teaser: |One-sentence teaser: )/i, "") - cleaned = cleaned.gsub(/^(This article |This page |This resource |Learn about |Discover |Explore )/i, "") - - # Remove quotes if the entire summary is quoted - cleaned = cleaned.gsub(/^["'](.+)["']$/, '\1') - - cleaned.strip - end - - def broadcast_update(post) - # Broadcast the summary update via Turbo Streams - Turbo::StreamsChannel.broadcast_replace_to( - "post_#{post.id}", - target: "post_#{post.id}_summary", - partial: "posts/summary", - locals: { post: post } - ) + post.generate_summary!(force: force) end end diff --git a/app/jobs/generate_testimonial_fields_job.rb b/app/jobs/generate_testimonial_fields_job.rb deleted file mode 100644 index cf1db19..0000000 --- a/app/jobs/generate_testimonial_fields_job.rb +++ /dev/null @@ -1,181 +0,0 @@ -class GenerateTestimonialFieldsJob < ApplicationJob - queue_as :default - - MAX_HEADING_RETRIES = 5 - - def perform(testimonial) - existing_headings = Testimonial.where.not(id: testimonial.id).where.not(heading: nil).pluck(:heading) - - user = testimonial.user - user_context = [ user.display_name, user.bio, user.company ].compact_blank.join(", ") - - system_prompt = build_system_prompt(existing_headings) - user_prompt = "User: #{user_context}\nQuote: #{testimonial.quote}" - - if testimonial.ai_feedback.present? && testimonial.ai_attempts > 0 - user_prompt += "\n\nPrevious feedback to address: #{testimonial.ai_feedback}" - end - - parsed = generate_fields(system_prompt, user_prompt) - - unless parsed - Rails.logger.error "Failed to generate testimonial fields for testimonial #{testimonial.id}" - testimonial.update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") - broadcast_update(testimonial) - return - end - - # If the heading collides, retry with the rejected heading added to the exclusion list - retries = 0 - while heading_taken?(parsed["heading"], testimonial.id) && retries < MAX_HEADING_RETRIES - retries += 1 - existing_headings << parsed["heading"] - retry_prompt = build_system_prompt(existing_headings) - parsed = generate_fields(retry_prompt, user_prompt) - break unless parsed - end - - unless parsed - testimonial.update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") - broadcast_update(testimonial) - return - end - - if heading_taken?(parsed["heading"], testimonial.id) - Rails.logger.warn "Testimonial #{testimonial.id}: heading '#{parsed["heading"]}' still collides after #{MAX_HEADING_RETRIES} retries, saving anyway" - end - - testimonial.update!( - heading: parsed["heading"], - subheading: parsed["subheading"], - body_text: parsed["body_text"] - ) - ValidateTestimonialJob.perform_later(testimonial) - rescue JSON::ParserError => e - Rails.logger.error "Failed to parse AI response for testimonial #{testimonial.id}: #{e.message}" - testimonial.update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") - broadcast_update(testimonial) - end - - private - - def build_system_prompt(existing_headings) - taken = if existing_headings.any? - "These headings are ALREADY TAKEN and must NOT be used (pick a synonym or related concept instead): #{existing_headings.join(', ')}." - else - "No headings are taken yet — pick any fitting word." - end - - <<~PROMPT - You generate structured testimonial content for a Ruby programming language advocacy site. - Given a user's quote about why they love Ruby, generate: - - 1. heading: A unique 1-3 word heading that captures the THEME or FEELING of the quote. - Be creative and specific. Go beyond generic words. Think of evocative nouns, metaphors, compound phrases, or poetic concepts. - The heading must make sense as an answer to "Why Ruby?" — e.g. "Why Ruby?" → "Flow State", "Clarity", "Pure Joy". - Good examples: "Spark", "Flow State", "Quiet Power", "Warm Glow", "First Love", "Playground", "Second Nature", "Deep Roots", "Readable Code", "Clean Slate", "Smooth Sailing", "Expressiveness", "Old Friend", "Sharp Tools", "Creative Freedom", "Solid Ground", "Calm Waters", "Poetic Logic", "Builder's Joy", "Sweet Spot", "Hidden Gem", "Fresh Start", "True North", "Clarity", "Belonging", "Empowerment", "Momentum", "Simplicity", "Trust", "Confidence" - #{taken} - 2. subheading: A short tagline under 10 words. - 3. body_text: 2-3 sentences that EXTEND and DEEPEN the user's idea. Add new angles, examples, or implications. - Do NOT repeat or paraphrase what the user already said. Build on top of it. - - WRITING STYLE — sound like a real person, not an AI: - - NEVER use: delve, tapestry, landscape, foster, showcase, underscore, pivotal, vibrant, crucial, testament, additionally, interplay, intricate, enduring, garner, enhance - - NEVER use inflated phrases: "serves as", "stands as", "is a testament to", "highlights the importance of", "reflects broader", "setting the stage" - - NEVER use "It's not just X, it's Y" or "Not only X but also Y" parallelisms - - NEVER use rule-of-three lists (e.g., "elegant, expressive, and powerful") - - NEVER end with vague positivity ("the future looks bright", "exciting times ahead") - - AVOID -ing tack-ons: "ensuring...", "highlighting...", "fostering..." - - AVOID em dashes. Use commas or periods instead. - - AVOID filler: "In order to", "It is important to note", "Due to the fact that" - - USE simple verbs: "is", "has", "does" — not "serves as", "boasts", "features" - - BE specific and concrete. Say what Ruby actually does, not how significant it is. - - Write like a developer talking to a friend, not a press release. - - Respond with valid JSON only: {"heading": "...", "subheading": "...", "body_text": "..."} - PROMPT - end - - def generate_fields(system_prompt, user_prompt) - result = nil - - if anthropic_configured? - result = generate_with_anthropic(system_prompt, user_prompt) - end - - if result.nil? && openai_configured? - result = generate_with_openai(system_prompt, user_prompt) - end - - result ? JSON.parse(result) : nil - end - - def broadcast_update(testimonial) - Turbo::StreamsChannel.broadcast_replace_to( - "testimonial_#{testimonial.id}", - target: "testimonial_section", - partial: "testimonials/section", - locals: { testimonial: testimonial, user: testimonial.user } - ) - end - - def heading_taken?(heading, testimonial_id) - Testimonial.where.not(id: testimonial_id).exists?(heading: heading) - end - - def anthropic_configured? - Rails.application.credentials.dig(:anthropic, :api_key).present? || - Rails.application.credentials.dig(:anthropic, :access_token).present? - end - - def openai_configured? - Rails.application.credentials.dig(:openai, :api_key).present? || - Rails.application.credentials.dig(:openai, :access_token).present? - end - - def generate_with_anthropic(system_prompt, user_prompt) - api_key = Rails.application.credentials.dig(:anthropic, :api_key).presence || - Rails.application.credentials.dig(:anthropic, :access_token) - - client = Anthropic::Client.new(api_key: api_key) - - response = client.messages( - parameters: { - model: "claude-3-haiku-20240307", - max_tokens: 300, - temperature: 0.8, - system: system_prompt, - messages: [ { role: "user", content: user_prompt } ] - } - ) - - response.dig("content", 0, "text") - rescue => e - Rails.logger.error "Anthropic API error in GenerateTestimonialFieldsJob: #{e.message}" - nil - end - - def generate_with_openai(system_prompt, user_prompt) - token = Rails.application.credentials.dig(:openai, :api_key).presence || - Rails.application.credentials.dig(:openai, :access_token) - - client = OpenAI::Client.new(access_token: token) - - response = client.chat( - parameters: { - model: "gpt-3.5-turbo", - messages: [ - { role: "system", content: system_prompt }, - { role: "user", content: user_prompt } - ], - temperature: 0.8, - max_tokens: 300 - } - ) - - response.dig("choices", 0, "message", "content") - rescue => e - Rails.logger.error "OpenAI API error in GenerateTestimonialFieldsJob: #{e.message}" - nil - end -end diff --git a/app/jobs/generate_testimonial_job.rb b/app/jobs/generate_testimonial_job.rb new file mode 100644 index 0000000..51e9b26 --- /dev/null +++ b/app/jobs/generate_testimonial_job.rb @@ -0,0 +1,7 @@ +class GenerateTestimonialJob < ApplicationJob + queue_as :default + + def perform(testimonial) + testimonial.generate_ai_fields! + end +end diff --git a/app/jobs/normalize_location_job.rb b/app/jobs/normalize_location_job.rb index 97a243f..ada1938 100644 --- a/app/jobs/normalize_location_job.rb +++ b/app/jobs/normalize_location_job.rb @@ -5,20 +5,6 @@ class NormalizeLocationJob < ApplicationJob def perform(user_id) user = User.find_by(id: user_id) - return unless user - - result = LocationNormalizer.normalize(user.location) - - if result - timezone = TimezoneResolver.resolve(result[:latitude], result[:longitude]) - user.update_columns( - normalized_location: result[:normalized_location], - latitude: result[:latitude], - longitude: result[:longitude], - timezone: timezone - ) - else - user.update_columns(normalized_location: nil, latitude: nil, longitude: nil, timezone: nil) - end + user&.geocode! end end diff --git a/app/jobs/translate_content_job.rb b/app/jobs/translate_content_job.rb new file mode 100644 index 0000000..7466b20 --- /dev/null +++ b/app/jobs/translate_content_job.rb @@ -0,0 +1,85 @@ +class TranslateContentJob < ApplicationJob + retry_on StandardError, wait: :polynomially_longer, attempts: 3 + + def perform(model_class_name, record_id, source_locale, target_locale) + record = model_class_name.constantize.find_by(id: record_id) + return unless record + + attributes = record.class.translatable_attributes + return if attributes.empty? + + # Collect source content + source_content = {} + Mobility.with_locale(source_locale) do + attributes.each do |attr| + value = record.send(attr) + source_content[attr] = value if value.present? + end + end + + return if source_content.empty? + + # Skip if all translations already exist for this locale + if translations_exist?(record, target_locale, source_content.keys) + return + end + + # Build translation prompt + target_language = Language.find_by(code: target_locale)&.name || target_locale + prompt = build_prompt(source_content, target_language) + + # Call LLM for translation + response = RubyLLM.chat(model: Setting.get(:translation_model, default: Setting::DEFAULT_AI_MODEL)).ask(prompt) + translated = parse_response(response.content, source_content.keys) + + return unless translated + + # Save translations + record.skip_translation_callbacks = true + Mobility.with_locale(target_locale) do + translated.each do |attr, value| + record.send("#{attr}=", value) + end + record.save! + end + ensure + record&.skip_translation_callbacks = false if record + end + + private + + def translations_exist?(record, locale, attributes) + conditions = { translatable_type: record.class.name, translatable_id: record.id, locale: locale.to_s, key: attributes.map(&:to_s) } + existing_keys = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation.where(conditions).pluck(:key) | + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation.where(conditions).pluck(:key) + (attributes.map(&:to_s) - existing_keys).empty? + end + + def build_prompt(content, target_language) + json_content = content.to_json + <<~PROMPT + Translate the following JSON values to #{target_language}. Keep the JSON keys unchanged. Respond with only valid JSON, no other text. + + #{json_content} + PROMPT + end + + def parse_response(response_text, expected_keys) + # Extract JSON from response (handle possible markdown code blocks) + json_str = response_text.strip + json_str = json_str.gsub(/\A```(?:json)?\n?/, "").gsub(/\n?```\z/, "") + + parsed = JSON.parse(json_str) + result = {} + + expected_keys.each do |key| + value = parsed[key] || parsed[key.to_s] + result[key] = value if value.present? + end + + result.presence + rescue JSON::ParserError => e + Rails.logger.warn "TranslateContentJob: Failed to parse LLM response: #{e.message}" + nil + end +end diff --git a/app/jobs/update_github_data_job.rb b/app/jobs/update_github_data_job.rb index 91c2482..f9acb25 100644 --- a/app/jobs/update_github_data_job.rb +++ b/app/jobs/update_github_data_job.rb @@ -1,7 +1,7 @@ class UpdateGithubDataJob < ApplicationJob queue_as :default - BATCH_SIZE = 5 # Server-side Ruby filtering means fewer repos returned + BATCH_SIZE = 5 def perform Rails.logger.info "Starting GitHub data update using GraphQL batch fetching..." @@ -11,17 +11,12 @@ def perform all_errors = [] User.where.not(username: [ nil, "" ]).find_in_batches(batch_size: BATCH_SIZE) do |batch| - Rails.logger.info "Processing batch of #{batch.size} users..." - - results = GithubDataFetcher.batch_fetch_and_update!(batch) + results = User.batch_sync_github_data!(batch) total_updated += results[:updated] total_failed += results[:failed] all_errors.concat(results[:errors]) if results[:errors].present? - Rails.logger.info "Batch complete: #{results[:updated]} updated, #{results[:failed]} failed" - - # Brief pause between batches to be respectful of API sleep 0.5 end diff --git a/app/jobs/validate_testimonial_job.rb b/app/jobs/validate_testimonial_job.rb index 4ccda7c..ba19183 100644 --- a/app/jobs/validate_testimonial_job.rb +++ b/app/jobs/validate_testimonial_job.rb @@ -1,169 +1,7 @@ class ValidateTestimonialJob < ApplicationJob queue_as :default - MAX_ATTEMPTS = 3 - def perform(testimonial) - existing = Testimonial.published.where.not(id: testimonial.id) - .pluck(:heading, :quote) - .map { |h, q| "Heading: #{h}, Quote: #{q}" } - .join("\n") - - system_prompt = <<~PROMPT - You validate testimonials for a Ruby programming language advocacy site. - - CONTENT POLICY: - - Hate speech, slurs, personal attacks, or targeted insults toward individuals or groups are NEVER allowed. - - Casual expletives used positively (e.g., "Damn, Ruby is amazing!" or "Fuck, I love this language!") are ALLOWED. - - The key distinction: profanity expressing enthusiasm = OK. Profanity attacking or demeaning people/groups = NOT OK. - - The quote MUST express genuine love or appreciation for Ruby. This is an advocacy site — negative, dismissive, sarcastic, or trolling sentiments about Ruby are NOT allowed. - - VALIDATION RULES: - 1. First check the user's QUOTE against the content policy. If it violates (including being negative about Ruby), reject immediately with reject_reason "quote". - 2. If the quote is fine, check the AI-generated fields (heading/subheading/body). ONLY reject generation if there is a CLEAR problem: - - The body contradicts or misrepresents the quote - - The subheading is nonsensical or unrelated - - The content is factually wrong about Ruby - Do NOT reject for duplicate headings (handled elsewhere). Do NOT reject just because the fields could be "better" or "more creative". Good enough is good enough — publish it. - 3. If everything looks acceptable, publish it. - - AI-SOUNDING LANGUAGE CHECK: - Reject with reason "generation" if the generated heading/subheading/body contains: - - Words: delve, tapestry, landscape, foster, showcase, underscore, pivotal, vibrant, crucial, testament, additionally, interplay, intricate, enduring, garner, enhance - - Patterns: "serves as", "stands as", "is a testament to", "not just X, it's Y", "not only X but also Y" - - Rule-of-three adjective/noun lists - - Vague positive endings ("the future looks bright", "exciting times ahead") - - Superficial -ing tack-ons ("ensuring...", "highlighting...", "fostering...") - If the quote itself is fine but the generated text sounds like AI wrote it, set reject_reason to "generation" and explain which phrases sound artificial. - - Existing published testimonials (for context): - #{existing.presence || "None yet."} - - Respond with valid JSON only: {"publish": true/false, "reject_reason": "quote" or "generation" or null, "feedback": "..."} - - reject_reason "quote": the user's quote violates content policy or is not meaningful. Feedback should tell the USER what to fix. - - reject_reason "generation": quote is fine but generated fields have a specific problem. Feedback must be a SPECIFIC INSTRUCTION for the AI generator, e.g., "The heading 'X' is already taken, use a different word" or "The body contradicts the quote by saying Y when the user said Z". Be concrete. - - reject_reason null: publishing. Feedback should be a short positive note for the user. - PROMPT - - user_prompt = <<~PROMPT - Quote: #{testimonial.quote} - Generated heading: #{testimonial.heading} - Generated subheading: #{testimonial.subheading} - Generated body: #{testimonial.body_text} - PROMPT - - result = nil - - if anthropic_configured? - result = generate_with_anthropic(system_prompt, user_prompt) - end - - if result.nil? && openai_configured? - result = generate_with_openai(system_prompt, user_prompt) - end - - if result - parsed = JSON.parse(result) - - if parsed["publish"] - testimonial.update!(published: true, ai_feedback: parsed["feedback"], reject_reason: nil) - elsif parsed["reject_reason"] == "quote" - testimonial.update!( - published: false, - ai_feedback: parsed["feedback"], - reject_reason: "quote" - ) - elsif testimonial.ai_attempts < MAX_ATTEMPTS - testimonial.update!( - ai_attempts: testimonial.ai_attempts + 1, - ai_feedback: parsed["feedback"], - reject_reason: "generation", - published: false - ) - GenerateTestimonialFieldsJob.perform_later(testimonial) - else - testimonial.update!( - published: false, - ai_feedback: parsed["feedback"], - reject_reason: "generation" - ) - end - else - Rails.logger.error "Failed to validate testimonial #{testimonial.id}" - testimonial.update!(ai_feedback: "We couldn't validate your testimonial right now. Please try again later.") - end - - broadcast_update(testimonial) - rescue JSON::ParserError => e - Rails.logger.error "Failed to parse validation response for testimonial #{testimonial.id}: #{e.message}" - testimonial.update!(ai_feedback: "We couldn't validate your testimonial right now. Please try again later.") - broadcast_update(testimonial) - end - - private - - def anthropic_configured? - Rails.application.credentials.dig(:anthropic, :api_key).present? || - Rails.application.credentials.dig(:anthropic, :access_token).present? - end - - def openai_configured? - Rails.application.credentials.dig(:openai, :api_key).present? || - Rails.application.credentials.dig(:openai, :access_token).present? - end - - def generate_with_anthropic(system_prompt, user_prompt) - api_key = Rails.application.credentials.dig(:anthropic, :api_key).presence || - Rails.application.credentials.dig(:anthropic, :access_token) - - client = Anthropic::Client.new(api_key: api_key) - - response = client.messages( - parameters: { - model: "claude-3-haiku-20240307", - max_tokens: 300, - temperature: 0.3, - system: system_prompt, - messages: [ { role: "user", content: user_prompt } ] - } - ) - - response.dig("content", 0, "text") - rescue => e - Rails.logger.error "Anthropic API error in ValidateTestimonialJob: #{e.message}" - nil - end - - def generate_with_openai(system_prompt, user_prompt) - token = Rails.application.credentials.dig(:openai, :api_key).presence || - Rails.application.credentials.dig(:openai, :access_token) - - client = OpenAI::Client.new(access_token: token) - - response = client.chat( - parameters: { - model: "gpt-3.5-turbo", - messages: [ - { role: "system", content: system_prompt }, - { role: "user", content: user_prompt } - ], - temperature: 0.3, - max_tokens: 300 - } - ) - - response.dig("choices", 0, "message", "content") - rescue => e - Rails.logger.error "OpenAI API error in ValidateTestimonialJob: #{e.message}" - nil - end - - def broadcast_update(testimonial) - Turbo::StreamsChannel.broadcast_replace_to( - "testimonial_#{testimonial.id}", - target: "testimonial_section", - partial: "testimonials/section", - locals: { testimonial: testimonial, user: testimonial.user } - ) + testimonial.validate_with_ai! end end diff --git a/app/madmin/fields/gravatar_field.rb b/app/madmin/fields/gravatar_field.rb new file mode 100644 index 0000000..5614a26 --- /dev/null +++ b/app/madmin/fields/gravatar_field.rb @@ -0,0 +1,8 @@ +class GravatarField < Madmin::Field + def gravatar_url(size: 40) + return nil unless value.present? + + hash = Digest::MD5.hexdigest(value.downcase.strip) + "https://www.gravatar.com/avatar/#{hash}?s=#{size}&d=mp" + end +end diff --git a/app/madmin/fields/json_field.rb b/app/madmin/fields/json_field.rb new file mode 100644 index 0000000..b6bb34a --- /dev/null +++ b/app/madmin/fields/json_field.rb @@ -0,0 +1,12 @@ +class JsonField < Madmin::Field + def formatted_json(record) + val = value(record) + if val.present? + JSON.pretty_generate(val) + else + "{}" + end + rescue JSON::GeneratorError + val.to_s + end +end diff --git a/app/madmin/resources/active_storage/attachment_resource.rb b/app/madmin/resources/active_storage/attachment_resource.rb new file mode 100644 index 0000000..a683031 --- /dev/null +++ b/app/madmin/resources/active_storage/attachment_resource.rb @@ -0,0 +1,29 @@ +class ActiveStorage::AttachmentResource < Madmin::Resource + # Menu configuration - nest under "Active Storage" + menu parent: "Active Storage", position: 1 + + # Attributes + attribute :id, form: false + attribute :name + attribute :created_at, form: false + + # Associations + attribute :record + attribute :blob + + # Add scopes to easily filter records + # scope :published + + # Add actions to the resource's show page + # member_action do |record| + # link_to "Do Something", some_path + # end + + # Customize the display name of records in the admin area. + # def self.display_name(record) = record.name + + # Customize the default sort column and direction. + # def self.default_sort_column = "created_at" + # + # def self.default_sort_direction = "desc" +end diff --git a/app/madmin/resources/active_storage/blob_resource.rb b/app/madmin/resources/active_storage/blob_resource.rb new file mode 100644 index 0000000..fa6dbeb --- /dev/null +++ b/app/madmin/resources/active_storage/blob_resource.rb @@ -0,0 +1,38 @@ +class ActiveStorage::BlobResource < Madmin::Resource + # Menu configuration - nest under "Active Storage" + menu parent: "Active Storage", position: 2 + + # Attributes + attribute :id, form: false + attribute :key + attribute :filename + attribute :content_type + attribute :service_name + attribute :byte_size + attribute :checksum + attribute :created_at, form: false + attribute :analyzed + attribute :identified + attribute :composed + attribute :preview_image, index: false + + # Associations + attribute :attachments + attribute :variant_records + + # Add scopes to easily filter records + # scope :published + + # Add actions to the resource's show page + # member_action do |record| + # link_to "Do Something", some_path + # end + + # Customize the display name of records in the admin area. + # def self.display_name(record) = record.name + + # Customize the default sort column and direction. + # def self.default_sort_column = "created_at" + # + # def self.default_sort_direction = "desc" +end diff --git a/app/madmin/resources/active_storage/variant_record_resource.rb b/app/madmin/resources/active_storage/variant_record_resource.rb new file mode 100644 index 0000000..b100fdd --- /dev/null +++ b/app/madmin/resources/active_storage/variant_record_resource.rb @@ -0,0 +1,29 @@ +class ActiveStorage::VariantRecordResource < Madmin::Resource + # Menu configuration - nest under "Active Storage" + menu parent: "Active Storage", position: 3 + + # Attributes + attribute :id, form: false + attribute :variation, index: false, show: false + attribute :variation_confirmation, index: false, show: false + attribute :image, index: false + + # Associations + attribute :blob + + # Add scopes to easily filter records + # scope :published + + # Add actions to the resource's show page + # member_action do |record| + # link_to "Do Something", some_path + # end + + # Customize the display name of records in the admin area. + # def self.display_name(record) = record.name + + # Customize the default sort column and direction. + # def self.default_sort_column = "created_at" + # + # def self.default_sort_direction = "desc" +end diff --git a/app/madmin/resources/admin_resource.rb b/app/madmin/resources/admin_resource.rb new file mode 100644 index 0000000..6041c44 --- /dev/null +++ b/app/madmin/resources/admin_resource.rb @@ -0,0 +1,27 @@ +class AdminResource < Madmin::Resource + # Attributes + attribute :id, form: false, index: false + attribute :email, field: GravatarField, index: true, show: true, form: false + attribute :email # Regular field for editing + attribute :created_at, form: false + attribute :updated_at, form: false + + # Associations + + # Member action for sending magic link + member_action do |record| + button_to "Send Magic Link", + "/madmin/admins/#{record.id}/send_magic_link", + method: :post, + data: { turbo_confirm: "Send magic link to #{record.email}?" }, + class: "btn btn-primary" + end + + def self.searchable_attributes + [ :email ] + end + + def self.display_name(record) + record.email + end +end diff --git a/app/madmin/resources/category_resource.rb b/app/madmin/resources/category_resource.rb new file mode 100644 index 0000000..46862f6 --- /dev/null +++ b/app/madmin/resources/category_resource.rb @@ -0,0 +1,22 @@ +class CategoryResource < Madmin::Resource + def self.actions + [ :index, :show, :edit, :update ] + end + + attribute :id, form: false, index: false + attribute :name + attribute :slug, form: false + attribute :description + attribute :position + attribute :is_success_story + attribute :created_at, form: false + attribute :updated_at, form: false + + def self.searchable_attributes + [ :name ] + end + + def self.display_name(record) + record.name + end +end diff --git a/app/madmin/resources/chat_resource.rb b/app/madmin/resources/chat_resource.rb new file mode 100644 index 0000000..5366981 --- /dev/null +++ b/app/madmin/resources/chat_resource.rb @@ -0,0 +1,24 @@ +class ChatResource < Madmin::Resource + # Read-only resource + def self.actions + [ :index, :show ] + end + + # Attributes + attribute :id, form: false, index: false + attribute :created_at, form: false + attribute :updated_at, form: false + + # Associations + attribute :user + attribute :model + attribute :messages + + def self.searchable_attributes + [] # Custom search in controller + end + + def self.display_name(record) + "Chat with #{record.user.email}" if record.user + end +end diff --git a/app/madmin/resources/comment_resource.rb b/app/madmin/resources/comment_resource.rb new file mode 100644 index 0000000..ac66dfb --- /dev/null +++ b/app/madmin/resources/comment_resource.rb @@ -0,0 +1,21 @@ +class CommentResource < Madmin::Resource + def self.actions + [ :index, :show, :edit, :update ] + end + + attribute :id, form: false, index: false + attribute :body + attribute :published + attribute :user + attribute :post + attribute :created_at, form: false + attribute :updated_at, form: false + + def self.searchable_attributes + [ :body ] + end + + def self.display_name(record) + record.body.truncate(60) + end +end diff --git a/app/madmin/resources/language_resource.rb b/app/madmin/resources/language_resource.rb new file mode 100644 index 0000000..5d27cff --- /dev/null +++ b/app/madmin/resources/language_resource.rb @@ -0,0 +1,29 @@ +class LanguageResource < Madmin::Resource + def self.actions + [ :index, :show, :edit ] + end + + attribute :id, form: false + attribute :code, form: false, index: true + attribute :name, form: false, index: true + attribute :native_name, form: false, index: true + attribute :enabled, index: true + attribute :created_at, form: false + attribute :updated_at, form: false + + def self.index_attributes + [ :id, :code, :name, :native_name, :enabled ] + end + + def self.form_attributes + [ :enabled ] + end + + def self.searchable_attributes + [ :code, :name, :native_name ] + end + + def self.display_name(record) + "#{record.name} (#{record.code})" + end +end diff --git a/app/madmin/resources/message_resource.rb b/app/madmin/resources/message_resource.rb new file mode 100644 index 0000000..c4e6b7c --- /dev/null +++ b/app/madmin/resources/message_resource.rb @@ -0,0 +1,33 @@ +class MessageResource < Madmin::Resource + # Read-only resource + def self.actions + [ :index, :show ] + end + + # Attributes + attribute :id, form: false, index: false + attribute :role + attribute :content, :text + attribute :chat + attribute :model + attribute :input_tokens + attribute :output_tokens + attribute :cached_tokens + attribute :cache_creation_tokens + attribute :cost + attribute :content_raw, field: JsonField + attribute :tool_calls + attribute :created_at + attribute :updated_at + + # Associations + + def self.searchable_attributes + [ :content ] + end + + def self.display_name(record) + truncated = record.content.to_s.truncate(50) + "#{record.role}: #{truncated}" + end +end diff --git a/app/madmin/resources/model_resource.rb b/app/madmin/resources/model_resource.rb new file mode 100644 index 0000000..0c8c82a --- /dev/null +++ b/app/madmin/resources/model_resource.rb @@ -0,0 +1,28 @@ +class ModelResource < Madmin::Resource + # Attributes + attribute :id, form: false, index: false + attribute :name + attribute :model_id + attribute :provider, :select, collection: [ "openai", "anthropic" ] + attribute :family + attribute :context_window + attribute :max_output_tokens + attribute :knowledge_cutoff + attribute :modalities, field: JsonField, form: false + attribute :capabilities, field: JsonField, form: false + attribute :pricing, field: JsonField, form: false + attribute :metadata, field: JsonField, form: false + attribute :model_created_at, form: false + attribute :created_at, form: false + attribute :updated_at, form: false + + # Associations + + def self.searchable_attributes + [ :name, :model_id, :provider ] + end + + def self.display_name(record) + record.name + end +end diff --git a/app/madmin/resources/post_resource.rb b/app/madmin/resources/post_resource.rb new file mode 100644 index 0000000..4338380 --- /dev/null +++ b/app/madmin/resources/post_resource.rb @@ -0,0 +1,32 @@ +class PostResource < Madmin::Resource + def self.actions + [ :index, :show, :edit, :update ] + end + + attribute :id, form: false, index: false + attribute :title + attribute :post_type + attribute :published + attribute :needs_admin_review + attribute :pin_position + attribute :url + attribute :summary, form: false + attribute :user + attribute :category + attribute :comments_count, form: false + attribute :reports_count, form: false + attribute :created_at, form: false + attribute :updated_at, form: false + + def self.sortable_columns + super + %w[comments_count reports_count] + end + + def self.searchable_attributes + [ :title, :url ] + end + + def self.display_name(record) + record.title.truncate(60) + end +end diff --git a/app/madmin/resources/project_resource.rb b/app/madmin/resources/project_resource.rb new file mode 100644 index 0000000..cc67614 --- /dev/null +++ b/app/madmin/resources/project_resource.rb @@ -0,0 +1,30 @@ +class ProjectResource < Madmin::Resource + def self.actions + [ :index, :show ] + end + + attribute :id, form: false, index: false + attribute :name + attribute :github_url + attribute :description + attribute :stars + attribute :forks_count + attribute :hidden + attribute :archived + attribute :user + attribute :pushed_at + attribute :created_at, form: false + attribute :updated_at, form: false + + def self.sortable_columns + super + %w[stars forks_count] + end + + def self.searchable_attributes + [ :name, :github_url ] + end + + def self.display_name(record) + record.name + end +end diff --git a/app/madmin/resources/report_resource.rb b/app/madmin/resources/report_resource.rb new file mode 100644 index 0000000..a7a6cdc --- /dev/null +++ b/app/madmin/resources/report_resource.rb @@ -0,0 +1,21 @@ +class ReportResource < Madmin::Resource + def self.actions + [ :index, :show ] + end + + attribute :id, form: false, index: false + attribute :reason + attribute :description + attribute :user + attribute :post + attribute :created_at, form: false + attribute :updated_at, form: false + + def self.searchable_attributes + [ :description ] + end + + def self.display_name(record) + "#{record.reason.titleize} report on #{record.post.title.truncate(40)}" + end +end diff --git a/app/madmin/resources/tag_resource.rb b/app/madmin/resources/tag_resource.rb new file mode 100644 index 0000000..06fecb5 --- /dev/null +++ b/app/madmin/resources/tag_resource.rb @@ -0,0 +1,19 @@ +class TagResource < Madmin::Resource + def self.actions + [ :index, :show, :edit, :update ] + end + + attribute :id, form: false, index: false + attribute :name + attribute :slug, form: false + attribute :created_at, form: false + attribute :updated_at, form: false + + def self.searchable_attributes + [ :name ] + end + + def self.display_name(record) + record.name + end +end diff --git a/app/madmin/resources/team_resource.rb b/app/madmin/resources/team_resource.rb new file mode 100644 index 0000000..73b6f30 --- /dev/null +++ b/app/madmin/resources/team_resource.rb @@ -0,0 +1,40 @@ +class TeamResource < Madmin::Resource + # Read-only: teams are managed through the user interface + def self.actions + [ :index, :show ] + end + + def self.model_find(id) + model.find_by!(slug: id) + end + + # Attributes + attribute :id, form: false + attribute :name + attribute :slug + attribute :api_key + attribute :stripe_customer_id, form: false + attribute :subscription_status, form: false + attribute :current_period_ends_at, form: false + attribute :created_at, form: false + + # Associations + attribute :memberships + attribute :chats + + def self.index_attributes + [ :id, :name, :slug, :created_at ] + end + + def self.sortable_columns + super + %w[owner_name members_count chats_count total_cost] + end + + def self.searchable_attributes + [ :name, :slug ] + end + + def self.display_name(record) + record.name + end +end diff --git a/app/madmin/resources/testimonial_resource.rb b/app/madmin/resources/testimonial_resource.rb new file mode 100644 index 0000000..81a7129 --- /dev/null +++ b/app/madmin/resources/testimonial_resource.rb @@ -0,0 +1,27 @@ +class TestimonialResource < Madmin::Resource + def self.actions + [ :index, :show, :edit, :update ] + end + + attribute :id, form: false, index: false + attribute :quote + attribute :heading, form: false + attribute :subheading, form: false + attribute :body_text, form: false + attribute :published + attribute :position + attribute :ai_attempts, form: false + attribute :ai_feedback, form: false + attribute :reject_reason, form: false + attribute :user + attribute :created_at, form: false + attribute :updated_at, form: false + + def self.searchable_attributes + [ :quote, :heading ] + end + + def self.display_name(record) + record.heading.presence || record.quote&.truncate(60) || "Testimonial ##{record.id.first(8)}" + end +end diff --git a/app/madmin/resources/tool_call_resource.rb b/app/madmin/resources/tool_call_resource.rb new file mode 100644 index 0000000..67fb2bb --- /dev/null +++ b/app/madmin/resources/tool_call_resource.rb @@ -0,0 +1,20 @@ +class ToolCallResource < Madmin::Resource + # Attributes + attribute :id, form: false, index: false + attribute :name + attribute :tool_call_id + attribute :message + attribute :arguments, field: JsonField, form: false + attribute :created_at, form: false + attribute :updated_at, form: false + + # Associations + + def self.searchable_attributes + [ :name, :tool_call_id ] + end + + def self.display_name(record) + record.name + end +end diff --git a/app/madmin/resources/user_resource.rb b/app/madmin/resources/user_resource.rb new file mode 100644 index 0000000..e190434 --- /dev/null +++ b/app/madmin/resources/user_resource.rb @@ -0,0 +1,29 @@ +class UserResource < Madmin::Resource + def self.actions + [ :index, :show, :edit, :update ] + end + + attribute :id, form: false, index: false + attribute :username + attribute :name + attribute :email + attribute :role + attribute :location + attribute :published_posts_count, form: false + attribute :published_comments_count, form: false + attribute :github_stars_sum, form: false + attribute :created_at, form: false + attribute :updated_at, form: false + + def self.sortable_columns + super + %w[published_posts_count published_comments_count github_stars_sum] + end + + def self.searchable_attributes + [ :username, :name, :email ] + end + + def self.display_name(record) + record.display_name + end +end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb new file mode 100644 index 0000000..c667e3e --- /dev/null +++ b/app/mailers/admin_mailer.rb @@ -0,0 +1,12 @@ +class AdminMailer < ApplicationMailer + def magic_link(admin) + @admin = admin + @token = admin.generate_magic_link_token + @magic_link_url = admins_verify_magic_link_url(token: @token) + + mail( + to: @admin.email, + subject: t(".subject") + ) + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000..7bee9e7 --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,24 @@ +class UserMailer < ApplicationMailer + def magic_link(user) + @user = user + @token = user.generate_magic_link_token + @magic_link_url = verify_magic_link_url(token: @token) + + mail( + to: @user.email, + subject: t(".subject") + ) + end + + def team_invitation(user, team, invited_by, invite_url) + @user = user + @team = team + @invited_by = invited_by + @invite_url = invite_url + + mail( + to: @user.email, + subject: t(".subject", team_name: @team.name) + ) + end +end diff --git a/app/models/admin.rb b/app/models/admin.rb new file mode 100644 index 0000000..08ce5a6 --- /dev/null +++ b/app/models/admin.rb @@ -0,0 +1,7 @@ +class Admin < ApplicationRecord + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + + def generate_magic_link_token + signed_id(purpose: :magic_link, expires_in: 15.minutes) + end +end diff --git a/app/models/article.rb b/app/models/article.rb new file mode 100644 index 0000000..51c49d4 --- /dev/null +++ b/app/models/article.rb @@ -0,0 +1,13 @@ +class Article < ApplicationRecord + include Translatable + + belongs_to :team + belongs_to :user + + translatable :title, type: :string + translatable :body, type: :text + + validates :title, presence: true + + scope :recent, -> { order(created_at: :desc) } +end diff --git a/app/models/chat.rb b/app/models/chat.rb new file mode 100644 index 0000000..0e1fc6f --- /dev/null +++ b/app/models/chat.rb @@ -0,0 +1,27 @@ +class Chat < ApplicationRecord + include Costable + + belongs_to :user + belongs_to :team, optional: true + belongs_to :model, optional: true + acts_as_chat messages_foreign_key: :chat_id + + scope :chronologically, -> { order(created_at: :asc) } + scope :recent, -> { order(created_at: :desc) } + scope :conversations, -> { where(purpose: "conversation") } + scope :system, -> { where.not(purpose: "conversation") } + + after_destroy :update_costs_on_destroy + + # Recalculate total cost from messages + def recalculate_total_cost! + update_column(:total_cost, messages.sum(:cost)) + end + + private + + def update_costs_on_destroy + user&.recalculate_total_cost! + model&.recalculate_total_cost! + end +end diff --git a/app/models/concerns/costable.rb b/app/models/concerns/costable.rb new file mode 100644 index 0000000..a8ea95b --- /dev/null +++ b/app/models/concerns/costable.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Shared cost formatting and calculation for models with total_cost column +module Costable + extend ActiveSupport::Concern + + # Format total cost for display (e.g., "$0.0012" or "<$0.0001") + def formatted_total_cost + cost = read_attribute(:total_cost) || 0 + return nil if cost.zero? + + if cost < 0.0001 + "<$0.0001" + else + "$#{'%.4f' % cost}" + end + end +end diff --git a/app/models/concerns/post/ai_summarizable.rb b/app/models/concerns/post/ai_summarizable.rb new file mode 100644 index 0000000..70f587d --- /dev/null +++ b/app/models/concerns/post/ai_summarizable.rb @@ -0,0 +1,58 @@ +# app/models/concerns/post/ai_summarizable.rb +module Post::AiSummarizable + extend ActiveSupport::Concern + + def generate_summary!(force: false) + return if summary.present? && !force + + text_to_summarize = prepare_text_for_summary + return if text_to_summarize.blank? || text_to_summarize.length < 50 + + chat = user.chats.create!( + purpose: "summary", + model: Model.find_by(model_id: Setting.get(:summary_model, default: Setting::DEFAULT_AI_MODEL)) + ) + + prompt = "Output ONLY a single teaser sentence. No preamble. Maximum 200 characters. Hook the reader with the most intriguing aspect.\n\nTeaser:\n\n#{text_to_summarize}" + + response = chat.ask(prompt) + raw_summary = response.content + + return unless raw_summary.present? + + cleaned = clean_ai_summary(raw_summary) + update!(summary: cleaned) + broadcast_summary_update + rescue => e + Rails.logger.error "Failed to generate summary for post #{id}: #{e.message}" + end + + private + + def prepare_text_for_summary + if link? + text = fetch_external_content + text = "Title: #{title}\nURL: #{url}" if text.blank? + else + text = ActionView::Base.full_sanitizer.sanitize(content) + end + + text.to_s.truncate(6000) + end + + def clean_ai_summary(raw) + cleaned = raw.gsub(/^(Here is a |Here's a |Here are |Teaser: |The teaser: |One-sentence teaser: )/i, "") + cleaned = cleaned.gsub(/^(This article |This page |This resource |Learn about |Discover |Explore )/i, "") + cleaned = cleaned.gsub(/^["'](.+)["']$/, '\1') + cleaned.strip + end + + def broadcast_summary_update + Turbo::StreamsChannel.broadcast_replace_to( + "post_#{id}", + target: "post_#{id}_summary", + partial: "posts/summary", + locals: { post: self } + ) + end +end diff --git a/app/models/concerns/post/image_variantable.rb b/app/models/concerns/post/image_variantable.rb new file mode 100644 index 0000000..f813ff2 --- /dev/null +++ b/app/models/concerns/post/image_variantable.rb @@ -0,0 +1,167 @@ +# app/models/concerns/post/image_variantable.rb +module Post::ImageVariantable + extend ActiveSupport::Concern + + ALLOWED_CONTENT_TYPES = %w[ + image/jpeg image/jpg image/png image/webp image/tiff image/x-tiff + ].freeze + + IMAGE_VARIANTS = { + tile: { width: 684, height: 384, quality: 92 }, + post: { width: 1664, height: 936, quality: 94 }, + og: { width: 1200, height: 630, quality: 95 } + }.freeze + + MAX_IMAGE_SIZE = 20.megabytes + + # Generate all WebP variants from featured_image + def process_image_variants! + return { error: "No image attached" } unless featured_image.attached? + return { error: "File too large" } if featured_image.blob.byte_size > MAX_IMAGE_SIZE + return { error: "Invalid file type" } unless ALLOWED_CONTENT_TYPES.include?(featured_image.blob.content_type) + + variants = {} + + featured_image.blob.open do |tempfile| + IMAGE_VARIANTS.each do |name, config| + variant_blob = generate_image_variant(tempfile.path, config) + variants[name] = variant_blob.id if variant_blob + end + end + + update_columns(image_variants: variants) + { success: true, variants: variants } + rescue => e + Rails.logger.error "Image processing error: #{e.message}" + { error: "Processing failed: #{e.message}" } + end + + def image_variant(size = :medium) + return nil unless featured_image.attached? && image_variants.present? + + variant_id = image_variants[size.to_s] + return featured_image.blob unless variant_id + + ActiveStorage::Blob.find_by(id: variant_id) || featured_image.blob + end + + def image_url_for_size(size = :medium) + blob = image_variant(size) + return nil unless blob + + Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true) + end + + def has_processed_images? + image_variants.present? + end + + def reprocess_image! + return unless featured_image.attached? + + process_image_variants! + end + + def clear_image_variants! + if image_variants.present? + image_variants.each do |_size, blob_id| + ActiveStorage::Blob.find_by(id: blob_id)&.purge_later + end + end + + update_columns(image_variants: nil) + end + + # Attach image from URL and process variants + def attach_image_from_url!(image_url) + return if image_url.blank? + + require "open-uri" + + image_io = URI.open(image_url, + "User-Agent" => "Ruby/#{RUBY_VERSION}", + read_timeout: 10, + open_timeout: 10 + ) + + return if image_io.size > MAX_IMAGE_SIZE + + temp_file = Tempfile.new([ "remote_image", File.extname(URI.parse(image_url).path) ]) + temp_file.binmode + temp_file.write(image_io.read) + temp_file.rewind + + featured_image.attach(io: temp_file, filename: File.basename(URI.parse(image_url).path)) + process_image_variants! + rescue => e + Rails.logger.error "Failed to fetch/process image from URL #{image_url}: #{e.message}" + ensure + temp_file&.close + temp_file&.unlink + end + + private + + def generate_image_variant(source_path, config) + variant_file = Tempfile.new([ "variant", ".webp" ]) + + begin + cmd = [ + "convert", source_path, + "-resize", "#{config[:width]}x#{config[:height]}>", + "-filter", "Lanczos", + "-quality", config[:quality].to_s, + "-define", "webp:lossless=false", + "-define", "webp:method=6", + "-define", "webp:alpha-quality=100", + "-define", "webp:image-hint=photo", + "-strip", + "webp:#{variant_file.path}" + ] + + unless system(*cmd, err: File::NULL) + Rails.logger.error "Failed to generate variant: #{config.inspect}" + return nil + end + + ActiveStorage::Blob.create_and_upload!( + io: File.open(variant_file.path), + filename: "variant_#{config[:width]}x#{config[:height]}.webp", + content_type: "image/webp" + ) + ensure + variant_file.close + variant_file.unlink + end + end + + def process_featured_image_if_needed + return unless featured_image.attached? + + should_process = !has_processed_images? || + (previous_changes.key?("updated_at") && featured_image.blob.created_at > 1.minute.ago) + + return unless should_process + + Rails.logger.info "Processing image for Post ##{id}" + result = process_image_variants! + + if result[:success] + Rails.logger.info "Successfully processed image for Post ##{id}" + else + Rails.logger.error "Failed to process image for Post ##{id}: #{result[:error]}" + end + end + + def featured_image_validation + return unless featured_image.attached? + + if featured_image.blob.byte_size > MAX_IMAGE_SIZE + errors.add(:featured_image, "is too large (maximum is #{MAX_IMAGE_SIZE / 1.megabyte}MB)") + end + + unless ALLOWED_CONTENT_TYPES.include?(featured_image.blob.content_type) + errors.add(:featured_image, "must be a JPEG, PNG, WebP, or TIFF image") + end + end +end diff --git a/app/models/concerns/post/metadata_fetchable.rb b/app/models/concerns/post/metadata_fetchable.rb new file mode 100644 index 0000000..dab5f10 --- /dev/null +++ b/app/models/concerns/post/metadata_fetchable.rb @@ -0,0 +1,204 @@ +module Post::MetadataFetchable + extend ActiveSupport::Concern + + MAX_REDIRECTS = 3 + DEFAULT_TIMEOUT = 10 + DEFAULT_RETRIES = 1 + + # Fetch OpenGraph metadata from url + # Returns hash: { title:, description:, image_url:, parsed: } + def fetch_metadata!(options = {}) + return {} if url.blank? + + connection_timeout = options[:connection_timeout] || DEFAULT_TIMEOUT + read_timeout = options[:read_timeout] || DEFAULT_TIMEOUT + retries = options[:retries] || DEFAULT_RETRIES + + html = fetch_html_with_retries(url, connection_timeout, read_timeout, retries) + return {} unless html + + parsed = Nokogiri::HTML(html) + + { + title: best_title(parsed), + description: best_description(parsed), + image_url: best_image(parsed), + parsed: parsed + } + rescue => e + Rails.logger.error "Failed to fetch metadata from #{url}: #{e.message}" + {} + end + + # Fetch page text content (for AI summarization of link posts) + def fetch_external_content + return nil if url.blank? + + html = fetch_html_with_retries(url, 5, 5, 1) + return nil unless html + + parsed = Nokogiri::HTML(html) + + content_parts = [] + + title_text = best_title(parsed) + content_parts << "Title: #{title_text}" if title_text.present? + + desc_text = best_description(parsed) + content_parts << "Description: #{desc_text}" if desc_text.present? + + main_content = extract_main_content(parsed) + content_parts << main_content if main_content.present? + + if content_parts.length <= 2 + raw_text = parsed.css("body").text.squish rescue nil + content_parts << raw_text if raw_text.present? + end + + result = content_parts.join("\n\n") + result.presence + rescue => e + Rails.logger.error "Failed to fetch external content from #{url}: #{e.message}" + nil + end + + private + + def fetch_html_with_retries(target_url, connection_timeout, read_timeout, retries) + attempts = 0 + begin + attempts += 1 + fetch_html(target_url, connection_timeout, read_timeout) + rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT => e + if attempts <= retries + retry + else + Rails.logger.error "Failed to fetch #{target_url} after #{retries} retries: #{e.message}" + nil + end + rescue => e + Rails.logger.error "Error fetching #{target_url}: #{e.message}" + nil + end + end + + def fetch_html(target_url, connection_timeout, read_timeout, redirect_count = 0) + uri = URI(target_url) + return nil unless %w[http https].include?(uri.scheme&.downcase) + + request = Net::HTTP::Get.new(uri) + request["User-Agent"] = "Ruby/#{RUBY_VERSION} (WhyRuby.info)" + request["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + + response = Net::HTTP.start( + uri.hostname, uri.port, + use_ssl: uri.scheme == "https", + open_timeout: connection_timeout, + read_timeout: read_timeout + ) { |http| http.request(request) } + + case response + when Net::HTTPSuccess + response.body + when Net::HTTPRedirection + return nil if redirect_count >= MAX_REDIRECTS + location = response["location"] + return nil unless location + redirect_uri = URI.join(uri.to_s, location) + fetch_html(redirect_uri.to_s, connection_timeout, read_timeout, redirect_count + 1) + else + nil + end + end + + def best_title(parsed) + extract_meta(parsed, property: "og:title") || + extract_meta(parsed, name: "twitter:title") || + parsed.at_css("title")&.text&.strip || + parsed.at_css("h1")&.text&.strip + end + + def best_description(parsed) + extract_meta(parsed, property: "og:description") || + extract_meta(parsed, name: "twitter:description") || + extract_meta(parsed, name: "description") || + extract_first_paragraph(parsed) + end + + def best_image(parsed) + og_image = extract_meta(parsed, property: "og:image") + return resolve_metadata_url(og_image) if og_image + + twitter_image = extract_meta(parsed, name: "twitter:image") + return resolve_metadata_url(twitter_image) if twitter_image + + largest = find_largest_image(parsed) + return resolve_metadata_url(largest) if largest + + first_image = parsed.at_css("img")&.[]("src") + resolve_metadata_url(first_image) if first_image + end + + def extract_meta(parsed, property: nil, name: nil) + if property + parsed.at_css("meta[property='#{property}']")&.[]("content")&.strip + elsif name + parsed.at_css("meta[name='#{name}']")&.[]("content")&.strip + end + end + + def extract_first_paragraph(parsed) + parsed.css("p").each do |p| + text = p.text.strip + return text if text.length > 50 + end + nil + end + + def extract_main_content(parsed) + %w[main article [role="main"] .content #content .post-content .entry-content .article-body].each do |selector| + element = parsed.at_css(selector) + if element + text = element.text.squish + return text if text.length > 100 + end + end + + paragraphs = parsed.css("p").map(&:text).reject(&:blank?) + paragraphs.join(" ").presence + end + + def find_largest_image(parsed) + largest = nil + max_size = 0 + + parsed.css("img").each do |img| + width = img["width"].to_i + height = img["height"].to_i + next if width.zero? || height.zero? || width < 200 || height < 200 + + aspect_ratio = width.to_f / height + next if aspect_ratio < 0.33 || aspect_ratio > 3.0 + + size = width * height + if size > max_size + max_size = size + largest = img["src"] + end + end + + largest + end + + def resolve_metadata_url(path) + return nil if path.blank? + return path if path.start_with?("http://", "https://") + + begin + URI.join(url, path).to_s + rescue => e + Rails.logger.warn "Failed to resolve relative URL #{path}: #{e.message}" + path + end + end +end diff --git a/app/models/concerns/post/og_image_generatable.rb b/app/models/concerns/post/og_image_generatable.rb new file mode 100644 index 0000000..135714e --- /dev/null +++ b/app/models/concerns/post/og_image_generatable.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Post::OgImageGeneratable + extend ActiveSupport::Concern + + OG_TEMPLATE_PATH = Rails.root.join("app", "assets", "images", "success_story_template.webp") + OG_LOGO_MAX_WIDTH = 410 + OG_LOGO_MAX_HEIGHT = 190 + OG_LOGO_CENTER_X = 410 + OG_LOGO_CENTER_Y = 145 + + # Generate OG image by overlaying SVG logo on success story template + def generate_og_image!(force: false) + return unless success_story? && logo_svg.present? + return unless system("which", "convert", out: File::NULL, err: File::NULL) + + if force && featured_image.attached? + featured_image.purge + end + + return if !force && featured_image.attached? + + webp_data = composite_logo_on_template + return unless webp_data && !webp_data.empty? + + featured_image.attach( + io: StringIO.new(webp_data), + filename: "#{slug}-social.webp", + content_type: "image/webp" + ) + + process_image_variants! if featured_image.attached? + end + + private + + def composite_logo_on_template + svg_file = Tempfile.new([ "logo", ".svg" ]) + logo_file = Tempfile.new([ "logo_converted", ".webp" ]) + output_file = Tempfile.new([ "success_story", ".webp" ]) + + begin + svg_file.write(logo_svg) + svg_file.rewind + + return nil unless File.exist?(OG_TEMPLATE_PATH) + + converted = try_rsvg_convert(svg_file.path, logo_file.path) || + try_imagemagick_convert(svg_file.path, logo_file.path) + + return nil unless converted + + require "open3" + stdout, status = Open3.capture2("identify", "-format", "%wx%h", logo_file.path) + return nil unless status.success? + + logo_width, logo_height = stdout.strip.split("x").map(&:to_i) + x_offset = OG_LOGO_CENTER_X - (logo_width / 2) + y_offset = OG_LOGO_CENTER_Y - (logo_height / 2) + + composite_cmd = [ + "convert", OG_TEMPLATE_PATH.to_s, logo_file.path, + "-geometry", "+#{x_offset}+#{y_offset}", + "-composite", "-quality", "95", + "-define", "webp:method=4", + "webp:#{output_file.path}" + ] + + return nil unless system(*composite_cmd) + + File.read(output_file.path) + rescue => e + Rails.logger.error "Failed to generate success story image: #{e.message}" + nil + ensure + [ svg_file, logo_file, output_file ].each { |f| f.close; f.unlink } + end + end + + def try_rsvg_convert(svg_path, output_path) + return false unless system("which", "rsvg-convert", out: File::NULL, err: File::NULL) + + temp_high_res = Tempfile.new([ "high_res", ".png" ]) + + begin + rsvg_cmd = [ + "rsvg-convert", "--keep-aspect-ratio", + "--width", (OG_LOGO_MAX_WIDTH * 2).to_s, + "--height", (OG_LOGO_MAX_HEIGHT * 2).to_s, + "--background-color", "transparent", + svg_path, "--output", temp_high_res.path + ] + + return false unless system(*rsvg_cmd, err: File::NULL) + + resize_cmd = [ + "convert", temp_high_res.path, + "-resize", "#{OG_LOGO_MAX_WIDTH}x#{OG_LOGO_MAX_HEIGHT}>", + "-filter", "Lanczos", "-quality", "95", + "-background", "none", "-gravity", "center", + "-define", "webp:method=6", "-define", "webp:alpha-quality=100", + "webp:#{output_path}" + ] + + system(*resize_cmd) + ensure + temp_high_res.close + temp_high_res.unlink + end + end + + def try_imagemagick_convert(svg_path, output_path) + cmd = [ + "convert", "-background", "none", "-density", "300", + svg_path, + "-resize", "#{OG_LOGO_MAX_WIDTH}x#{OG_LOGO_MAX_HEIGHT}>", + "-filter", "Lanczos", "-quality", "95", + "-gravity", "center", + "-define", "webp:method=6", "-define", "webp:alpha-quality=100", + "webp:#{output_path}" + ] + + system(*cmd) + end +end diff --git a/app/models/concerns/post/svg_sanitizable.rb b/app/models/concerns/post/svg_sanitizable.rb new file mode 100644 index 0000000..23a96c3 --- /dev/null +++ b/app/models/concerns/post/svg_sanitizable.rb @@ -0,0 +1,149 @@ +module Post::SvgSanitizable + extend ActiveSupport::Concern + + # Allowed SVG elements + ALLOWED_SVG_ELEMENTS = %w[ + svg g path rect circle ellipse line polyline polygon text tspan textPath + defs pattern clipPath mask linearGradient radialGradient stop symbol use + image desc title metadata + ].freeze + + # Allowed attributes (no event handlers) + ALLOWED_SVG_ATTRIBUTES = %w[ + style class + viewbox preserveaspectratio + x y x1 y1 x2 y2 cx cy r rx ry + d points fill stroke stroke-width stroke-linecap stroke-linejoin + fill-opacity stroke-opacity opacity + transform translate rotate scale + font-family font-size font-weight text-anchor + href xlink:href + offset stop-color stop-opacity + gradientunits gradienttransform + patternunits patterntransform + clip-path mask + xmlns xmlns:xlink version + ].map(&:downcase).freeze + + DANGEROUS_SVG_PATTERNS = [ + /<script[\s>]/i, + /<\/script>/i, + /javascript:/i, + /on\w+\s*=/i, + /data:text\/html/i, + /vbscript:/i, + /behavior:/i, + /expression\(/i, + /-moz-binding:/i + ].freeze + + # Sanitize the logo_svg attribute in place + def sanitize_logo_svg! + return if logo_svg.blank? + + self.logo_svg = self.class.sanitize_svg(logo_svg) + end + + class_methods do + def sanitize_svg(svg_content) + return "" if svg_content.blank? + + svg_content = fix_svg_case_sensitivity(svg_content) + + DANGEROUS_SVG_PATTERNS.each do |pattern| + svg_content = svg_content.gsub(pattern, "") + end + + begin + doc = Nokogiri::XML::DocumentFragment.parse(svg_content) do |config| + config.nonet + config.noent + end + rescue => e + Rails.logger.error "Failed to parse SVG: #{e.message}" + return "" + end + + svg_element = if doc.children.any? { |c| c.name.downcase == "svg" } + doc.children.find { |c| c.name.downcase == "svg" } + else + doc.at_css("svg") || doc.at_xpath("//svg") + end + + return "" unless svg_element + + svg_element.css("*").each do |element| + unless ALLOWED_SVG_ELEMENTS.include?(element.name.downcase) + element.remove + next + end + + element.attributes.keys.each do |name| + unless ALLOWED_SVG_ATTRIBUTES.include?(name.downcase) + element.remove_attribute(name) + end + end + + if element["style"]&.match?(/javascript:|expression\(|behavior:|binding:|@import/i) + element.remove_attribute("style") + end + + %w[href xlink:href].each do |attr| + if element[attr]&.match?(/^javascript:/i) + element.remove_attribute(attr) + end + end + end + + original_width = svg_element["width"] + original_height = svg_element["height"] + + svg_element.attributes.keys.each do |name| + unless ALLOWED_SVG_ATTRIBUTES.include?(name.downcase) + svg_element.remove_attribute(name) + end + end + + if svg_element["viewBox"].blank? && svg_element["viewbox"].blank? + if original_width && original_height + width_val = original_width.to_s.gsub(/[^\d.]/, "").to_f + height_val = original_height.to_s.gsub(/[^\d.]/, "").to_f + + if width_val > 0 && height_val > 0 + svg_element["viewBox"] = "0 0 #{width_val} #{height_val}" + end + end + end + + svg_element.to_xml + end + + private + + def fix_svg_case_sensitivity(svg_content) + fixed = svg_content.dup + fixed.gsub!(/\bviewbox=/i, "viewBox=") + fixed.gsub!(/\bpreserveaspectratio=/i, "preserveAspectRatio=") + fixed.gsub!(/\bgradientunits=/i, "gradientUnits=") + fixed.gsub!(/\bgradienttransform=/i, "gradientTransform=") + fixed.gsub!(/\bpatternunits=/i, "patternUnits=") + fixed.gsub!(/\bpatterntransform=/i, "patternTransform=") + fixed.gsub!(/\bclippath=/i, "clipPath=") + fixed.gsub!(/\btextlength=/i, "textLength=") + fixed.gsub!(/\blengthadjust=/i, "lengthAdjust=") + fixed.gsub!(/\bbaseprofile=/i, "baseProfile=") + fixed.gsub!(/\bmarkerwidth=/i, "markerWidth=") + fixed.gsub!(/\bmarkerheight=/i, "markerHeight=") + fixed.gsub!(/\bmarkerunits=/i, "markerUnits=") + fixed.gsub!(/\brefx=/i, "refX=") + fixed.gsub!(/\brefy=/i, "refY=") + fixed.gsub!(/\bpathlength=/i, "pathLength=") + fixed.gsub!(/\bstrokedasharray=/i, "strokeDasharray=") + fixed.gsub!(/\bstrokedashoffset=/i, "strokeDashoffset=") + fixed.gsub!(/\bstrokelinecap=/i, "strokeLinecap=") + fixed.gsub!(/\bstrokelinejoin=/i, "strokeLinejoin=") + fixed.gsub!(/\bstrokemiterlimit=/i, "strokeMiterlimit=") + fixed + end + end +end diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb new file mode 100644 index 0000000..74c38d5 --- /dev/null +++ b/app/models/concerns/subscribable.rb @@ -0,0 +1,101 @@ +module Subscribable + extend ActiveSupport::Concern + + included do + scope :subscribed, -> { where(subscription_status: %w[active trialing]) } + scope :past_due, -> { where(subscription_status: "past_due") } + scope :unsubscribed, -> { where(subscription_status: [ nil, "canceled", "incomplete_expired" ]) } + end + + def subscribed? + return false if trial_expired? + + subscription_status.in?(%w[active trialing]) + end + + def subscription_active? + subscribed? + end + + def past_due? + subscription_status == "past_due" + end + + def trialing? + subscription_status == "trialing" && !trial_expired? + end + + def trial_expired? + subscription_status == "trialing" && current_period_ends_at.present? && current_period_ends_at.past? + end + + def trial_days_remaining + return 0 unless trialing? + + ((current_period_ends_at - Time.current) / 1.day).ceil + end + + def cancellation_pending? + subscription_status == "active" && cancel_at_period_end? + end + + def canceled? + subscription_status == "canceled" + end + + def create_or_get_stripe_customer + return Stripe::Customer.retrieve(stripe_customer_id) if stripe_customer_id.present? + + customer = Stripe::Customer.create( + name: name, + metadata: { team_id: id, team_slug: slug } + ) + update!(stripe_customer_id: customer.id) + customer + end + + def create_checkout_session(price_id:, success_url:, cancel_url:) + customer = create_or_get_stripe_customer + + Stripe::Checkout::Session.create( + customer: customer.id, + mode: "subscription", + line_items: [ { price: price_id, quantity: 1 } ], + success_url: success_url, + cancel_url: cancel_url + ) + end + + def create_billing_portal_session(return_url:) + Stripe::BillingPortal::Session.create( + customer: stripe_customer_id, + return_url: return_url + ) + end + + def cancel_subscription! + return unless stripe_subscription_id.present? + + Stripe::Subscription.update(stripe_subscription_id, cancel_at_period_end: true) + sync_subscription_from_stripe! + end + + def resume_subscription! + return unless stripe_subscription_id.present? + + Stripe::Subscription.update(stripe_subscription_id, cancel_at_period_end: false) + sync_subscription_from_stripe! + end + + def sync_subscription_from_stripe! + return unless stripe_subscription_id.present? + + subscription = Stripe::Subscription.retrieve(stripe_subscription_id) + period_end = subscription.items.data.first["current_period_end"] + update!( + subscription_status: subscription.status, + current_period_ends_at: period_end ? Time.at(period_end).utc : nil, + cancel_at_period_end: subscription.cancel_at_period_end || false + ) + end +end diff --git a/app/models/concerns/testimonial/ai_generatable.rb b/app/models/concerns/testimonial/ai_generatable.rb new file mode 100644 index 0000000..6788b7c --- /dev/null +++ b/app/models/concerns/testimonial/ai_generatable.rb @@ -0,0 +1,109 @@ +module Testimonial::AiGeneratable + extend ActiveSupport::Concern + + MAX_HEADING_RETRIES = 5 + + def generate_ai_fields! + existing_headings = Testimonial.where.not(id: id).where.not(heading: nil).pluck(:heading) + + user_context = [ user.display_name, user.bio, user.company ].compact_blank.join(", ") + system_prompt = build_generation_prompt(existing_headings) + user_prompt = "User: #{user_context}\nQuote: #{quote}" + + if ai_feedback.present? && ai_attempts > 0 + user_prompt += "\n\nPrevious feedback to address: #{ai_feedback}" + end + + chat = user.chats.create!( + purpose: "testimonial_generation", + model: Model.find_by(model_id: Setting.get(:testimonial_model, default: Setting::DEFAULT_AI_MODEL)) + ) + + parsed = ask_and_parse(chat, system_prompt, user_prompt) + + unless parsed + update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") + broadcast_testimonial_update + return + end + + retries = 0 + while heading_taken?(parsed["heading"]) && retries < MAX_HEADING_RETRIES + retries += 1 + existing_headings << parsed["heading"] + retry_prompt = build_generation_prompt(existing_headings) + parsed = ask_and_parse(chat, retry_prompt, user_prompt) + break unless parsed + end + + unless parsed + update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") + broadcast_testimonial_update + return + end + + update!( + heading: parsed["heading"], + subheading: parsed["subheading"], + body_text: parsed["body_text"] + ) + ValidateTestimonialJob.perform_later(self) + rescue JSON::ParserError => e + Rails.logger.error "Failed to parse AI response for testimonial #{id}: #{e.message}" + update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") + broadcast_testimonial_update + end + + private + + def ask_and_parse(chat, system_prompt, user_prompt) + response = chat.ask("#{system_prompt}\n\n#{user_prompt}") + JSON.parse(response.content) + rescue JSON::ParserError + nil + rescue => e + Rails.logger.error "AI error in testimonial generation for #{id}: #{e.message}" + nil + end + + def heading_taken?(heading) + Testimonial.where.not(id: id).exists?(heading: heading) + end + + def build_generation_prompt(existing_headings) + taken = if existing_headings.any? + "These headings are ALREADY TAKEN and must NOT be used (pick a synonym or related concept instead): #{existing_headings.join(', ')}." + else + "No headings are taken yet — pick any fitting word." + end + + <<~PROMPT + You generate structured testimonial content for a Ruby programming language advocacy site. + Given a user's quote about why they love Ruby, generate: + + 1. heading: A unique 1-3 word heading that captures the THEME or FEELING of the quote. + Be creative and specific. Go beyond generic words. Think of evocative nouns, metaphors, compound phrases, or poetic concepts. + The heading must make sense as an answer to "Why Ruby?" — e.g. "Why Ruby?" → "Flow State", "Clarity", "Pure Joy". + Good examples: "Spark", "Flow State", "Quiet Power", "Warm Glow", "First Love", "Playground", "Second Nature", "Deep Roots", "Readable Code", "Clean Slate", "Smooth Sailing", "Expressiveness", "Old Friend", "Sharp Tools", "Creative Freedom", "Solid Ground", "Calm Waters", "Poetic Logic", "Builder's Joy", "Sweet Spot", "Hidden Gem", "Fresh Start", "True North", "Clarity", "Belonging", "Empowerment", "Momentum", "Simplicity", "Trust", "Confidence" + #{taken} + 2. subheading: A short tagline under 10 words. + 3. body_text: 2-3 sentences that EXTEND and DEEPEN the user's idea. Add new angles, examples, or implications. + Do NOT repeat or paraphrase what the user already said. Build on top of it. + + WRITING STYLE — sound like a real person, not an AI: + - NEVER use: delve, tapestry, landscape, foster, showcase, underscore, pivotal, vibrant, crucial, testament, additionally, interplay, intricate, enduring, garner, enhance + - NEVER use inflated phrases: "serves as", "stands as", "is a testament to", "highlights the importance of", "reflects broader", "setting the stage" + - NEVER use "It's not just X, it's Y" or "Not only X but also Y" parallelisms + - NEVER use rule-of-three lists (e.g., "elegant, expressive, and powerful") + - NEVER end with vague positivity ("the future looks bright", "exciting times ahead") + - AVOID -ing tack-ons: "ensuring...", "highlighting...", "fostering..." + - AVOID em dashes. Use commas or periods instead. + - AVOID filler: "In order to", "It is important to note", "Due to the fact that" + - USE simple verbs: "is", "has", "does" — not "serves as", "boasts", "features" + - BE specific and concrete. Say what Ruby actually does, not how significant it is. + - Write like a developer talking to a friend, not a press release. + + Respond with valid JSON only: {"heading": "...", "subheading": "...", "body_text": "..."} + PROMPT + end +end diff --git a/app/models/concerns/testimonial/ai_validatable.rb b/app/models/concerns/testimonial/ai_validatable.rb new file mode 100644 index 0000000..9556db0 --- /dev/null +++ b/app/models/concerns/testimonial/ai_validatable.rb @@ -0,0 +1,95 @@ +module Testimonial::AiValidatable + extend ActiveSupport::Concern + + MAX_VALIDATION_ATTEMPTS = 3 + + def validate_with_ai! + existing = Testimonial.published.where.not(id: id) + .pluck(:heading, :quote) + .map { |h, q| "Heading: #{h}, Quote: #{q}" } + .join("\n") + + system_prompt = build_validation_prompt(existing) + + user_prompt = <<~PROMPT + Quote: #{quote} + Generated heading: #{heading} + Generated subheading: #{subheading} + Generated body: #{body_text} + PROMPT + + chat = user.chats.create!( + purpose: "testimonial_validation", + model: Model.find_by(model_id: Setting.get(:validation_model, default: Setting::DEFAULT_AI_MODEL)) + ) + + response = chat.ask("#{system_prompt}\n\n#{user_prompt}") + parsed = JSON.parse(response.content) + + if parsed["publish"] + update!(published: true, ai_feedback: parsed["feedback"], reject_reason: nil) + elsif parsed["reject_reason"] == "quote" + update!(published: false, ai_feedback: parsed["feedback"], reject_reason: "quote") + elsif ai_attempts < MAX_VALIDATION_ATTEMPTS + update!( + ai_attempts: ai_attempts + 1, + ai_feedback: parsed["feedback"], + reject_reason: "generation", + published: false + ) + GenerateTestimonialJob.perform_later(self) + else + update!(published: false, ai_feedback: parsed["feedback"], reject_reason: "generation") + end + + broadcast_testimonial_update + rescue JSON::ParserError => e + Rails.logger.error "Failed to parse validation response for testimonial #{id}: #{e.message}" + update!(ai_feedback: "We couldn't validate your testimonial right now. Please try again later.") + broadcast_testimonial_update + rescue => e + Rails.logger.error "Failed to validate testimonial #{id}: #{e.message}" + update!(ai_feedback: "We couldn't validate your testimonial right now. Please try again later.") + broadcast_testimonial_update + end + + private + + def build_validation_prompt(existing_testimonials) + <<~PROMPT + You validate testimonials for a Ruby programming language advocacy site. + + CONTENT POLICY: + - Hate speech, slurs, personal attacks, or targeted insults toward individuals or groups are NEVER allowed. + - Casual expletives used positively (e.g., "Damn, Ruby is amazing!" or "Fuck, I love this language!") are ALLOWED. + - The key distinction: profanity expressing enthusiasm = OK. Profanity attacking or demeaning people/groups = NOT OK. + - The quote MUST express genuine love or appreciation for Ruby. This is an advocacy site — negative, dismissive, sarcastic, or trolling sentiments about Ruby are NOT allowed. + + VALIDATION RULES: + 1. First check the user's QUOTE against the content policy. If it violates (including being negative about Ruby), reject immediately with reject_reason "quote". + 2. If the quote is fine, check the AI-generated fields (heading/subheading/body). ONLY reject generation if there is a CLEAR problem: + - The body contradicts or misrepresents the quote + - The subheading is nonsensical or unrelated + - The content is factually wrong about Ruby + Do NOT reject for duplicate headings (handled elsewhere). Do NOT reject just because the fields could be "better" or "more creative". Good enough is good enough — publish it. + 3. If everything looks acceptable, publish it. + + AI-SOUNDING LANGUAGE CHECK: + Reject with reason "generation" if the generated heading/subheading/body contains: + - Words: delve, tapestry, landscape, foster, showcase, underscore, pivotal, vibrant, crucial, testament, additionally, interplay, intricate, enduring, garner, enhance + - Patterns: "serves as", "stands as", "is a testament to", "not just X, it's Y", "not only X but also Y" + - Rule-of-three adjective/noun lists + - Vague positive endings ("the future looks bright", "exciting times ahead") + - Superficial -ing tack-ons ("ensuring...", "highlighting...", "fostering...") + If the quote itself is fine but the generated text sounds like AI wrote it, set reject_reason to "generation" and explain which phrases sound artificial. + + Existing published testimonials (for context): + #{existing_testimonials.presence || "None yet."} + + Respond with valid JSON only: {"publish": true/false, "reject_reason": "quote" or "generation" or null, "feedback": "..."} + - reject_reason "quote": the user's quote violates content policy or is not meaningful. Feedback should tell the USER what to fix. + - reject_reason "generation": quote is fine but generated fields have a specific problem. Feedback must be a SPECIFIC INSTRUCTION for the AI generator, e.g., "The heading 'X' is already taken, use a different word" or "The body contradicts the quote by saying Y when the user said Z". Be concrete. + - reject_reason null: publishing. Feedback should be a short positive note for the user. + PROMPT + end +end diff --git a/app/models/concerns/translatable.rb b/app/models/concerns/translatable.rb new file mode 100644 index 0000000..f905348 --- /dev/null +++ b/app/models/concerns/translatable.rb @@ -0,0 +1,63 @@ +module Translatable + extend ActiveSupport::Concern + + included do + extend Mobility + + class_attribute :translatable_attributes, default: [] + + attr_accessor :skip_translation_callbacks + + after_commit :queue_translations, on: [ :create, :update ] + + Translatable.register(self) + end + + def self.registry + @registry ||= [] + end + + def self.register(klass) + registry << klass unless registry.include?(klass) + end + + class_methods do + # Declare translatable attributes and set up Mobility. + # Each attribute needs an explicit type (:string or :text). + # + # translatable :title, type: :string + # translatable :body, type: :text + # + def translatable(attribute, type: :string) + self.translatable_attributes = translatable_attributes + [ attribute.to_s ] + translates attribute, backend: :key_value, type: type + end + end + + def source_locale + I18n.locale + end + + private + + def queue_translations + return if skip_translation_callbacks + return unless translatable_attributes_changed? + return unless respond_to?(:team) && team.present? + + target_codes = team.translation_target_codes(exclude: source_locale.to_s) + return if target_codes.empty? + + jobs = target_codes.map do |target_locale| + TranslateContentJob.new(self.class.name, id, source_locale.to_s, target_locale) + end + + ActiveJob.perform_all_later(jobs) + end + + def translatable_attributes_changed? + return true if previously_new_record? + + (previous_changes.keys & self.class.translatable_attributes).any? + end +end diff --git a/app/models/concerns/user/geocodable.rb b/app/models/concerns/user/geocodable.rb new file mode 100644 index 0000000..16e3c26 --- /dev/null +++ b/app/models/concerns/user/geocodable.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "net/http" +require "json" + +module User::Geocodable + extend ActiveSupport::Concern + + PHOTON_API = "https://photon.komoot.io/api/" + + # Legacy IANA identifiers renamed in recent tzdata releases. + LEGACY_TIMEZONES = { + "Europe/Kiev" => "Europe/Kyiv" + }.freeze + + GeoResult = Data.define(:city, :state, :country_code, :latitude, :longitude) + + # Geocode user location string, setting lat/lng/normalized_location/timezone + def geocode! + result = photon_search(location) + + unless result + update_columns(normalized_location: nil, latitude: nil, longitude: nil, timezone: nil) + return + end + + normalized_string = build_normalized_string(result) + + unless normalized_string + update_columns(normalized_location: nil, latitude: nil, longitude: nil, timezone: nil) + return + end + + timezone = resolve_timezone(result.latitude, result.longitude) + + update_columns( + normalized_location: normalized_string, + latitude: result.latitude, + longitude: result.longitude, + timezone: timezone + ) + end + + private + + def photon_search(query) + return nil if query.blank? + + # Skip strings that are clearly not geographic (pure emoji, etc.) + stripped = query.gsub(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/, "").strip + return nil if stripped.empty? + + uri = URI(PHOTON_API) + uri.query = URI.encode_www_form(q: query, limit: 1) + + response = Net::HTTP.get_response(uri) + return nil unless response.is_a?(Net::HTTPSuccess) + + data = JSON.parse(response.body) + feature = data.dig("features", 0) + return nil unless feature + + properties = feature["properties"] + return nil unless properties + + coordinates = feature.dig("geometry", "coordinates") + lon, lat = coordinates if coordinates.is_a?(Array) && coordinates.size >= 2 + + GeoResult.new( + city: properties["city"] || (properties["type"] == "city" ? properties["name"] : nil), + state: properties["state"], + country_code: properties["countrycode"], + latitude: lat&.to_f, + longitude: lon&.to_f + ) + rescue StandardError => e + Rails.logger.warn "Photon geocoding failed: #{e.message}" + nil + end + + def build_normalized_string(result) + country_code = result.country_code&.upcase + return nil unless country_code.present? + + if result.city.present? + "#{result.city}, #{country_code}" + elsif result.state.present? + "#{result.state}, #{country_code}" + else + country_code + end + end + + def resolve_timezone(latitude, longitude) + return "Etc/UTC" if latitude.nil? || longitude.nil? + + result = WhereTZ.lookup(latitude, longitude) + timezone = result.is_a?(Array) ? result.first : result + normalize_timezone(timezone) + rescue StandardError => e + Rails.logger.warn "Timezone lookup failed for (#{latitude}, #{longitude}): #{e.message}" + "Etc/UTC" + end + + def normalize_timezone(timezone) + return "Etc/UTC" if timezone.blank? + + normalized = LEGACY_TIMEZONES[timezone] || timezone + TZInfo::Timezone.get(normalized) + normalized + rescue TZInfo::InvalidTimezoneIdentifier + Rails.logger.warn "Unknown timezone identifier: #{timezone}, falling back to Etc/UTC" + "Etc/UTC" + end +end diff --git a/app/models/concerns/user/github_syncable.rb b/app/models/concerns/user/github_syncable.rb new file mode 100644 index 0000000..ea392a8 --- /dev/null +++ b/app/models/concerns/user/github_syncable.rb @@ -0,0 +1,278 @@ +require "net/http" +require "json" + +module User::GithubSyncable + extend ActiveSupport::Concern + + GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql" + + # Sync from OAuth callback data + def sync_github_data_from_oauth!(auth_data) + api_token = auth_data.credentials.token + + if auth_data.extra&.raw_info + raw_info = auth_data.extra.raw_info + update!( + username: auth_data.info.nickname, + email: auth_data.info.email, + name: raw_info.name || name, + bio: raw_info.bio || bio, + company: raw_info.company, + website: raw_info.blog.presence || website, + twitter: raw_info.twitter_username.presence || twitter, + location: raw_info.location, + avatar_url: auth_data.info.image + ) + end + + github_username = auth_data.info.nickname || username + return unless github_username.present? + + repos = fetch_ruby_repositories(github_username, api_token) + if repos.present? + repos.each { |r| r[:github_url] ||= r.delete(:url) } + self.class.sync_projects!(self, repos) + end + + update!(github_data_updated_at: Time.current) + + # Assign to company team based on GitHub company field + Team.find_or_create_for_user!(self) + rescue => e + Rails.logger.error "Failed to sync GitHub data for #{username}: #{e.message}" + end + + class_methods do + # Batch GraphQL fetch for multiple users + def batch_sync_github_data!(users, api_token: nil) + api_token ||= Setting.get(:github_api_token) + return { updated: 0, failed: users.size, errors: [ "No API token configured" ] } unless api_token.present? + + users_with_usernames = users.select { |u| u.username.present? } + return { updated: 0, failed: 0, errors: [] } if users_with_usernames.empty? + + query = build_batch_query(users_with_usernames) + response = github_graphql_request(query, api_token, retries: 2) + + if response[:errors].present? && response[:data].nil? + error_msg = response[:errors].first.to_s + if error_msg.match?(/50[234]/) && users_with_usernames.size > 1 + Rails.logger.warn "Batch of #{users_with_usernames.size} failed with #{error_msg}, splitting in half..." + mid = users_with_usernames.size / 2 + first_half = batch_sync_github_data!(users_with_usernames[0...mid], api_token: api_token) + sleep(1) + second_half = batch_sync_github_data!(users_with_usernames[mid..], api_token: api_token) + + return { + updated: first_half[:updated] + second_half[:updated], + failed: first_half[:failed] + second_half[:failed], + errors: first_half[:errors] + second_half[:errors] + } + end + + return { updated: 0, failed: users_with_usernames.size, errors: response[:errors] } + end + + updated = 0 + failed = 0 + errors = [] + + users_with_usernames.each_with_index do |user, index| + user_data = response.dig(:data, :"user_#{index}") + repos_data = response.dig(:data, :"repos_#{index}", :nodes) + + if user_data.nil? + failed += 1 + errors << "User #{user.username} not found on GitHub" + next + end + + begin + update_user_from_graphql(user, user_data, repos_data || []) + Team.find_or_create_for_user!(user) + updated += 1 + rescue => e + failed += 1 + errors << "Failed to update #{user.username}: #{e.message}" + Rails.logger.error "GraphQL batch update error for #{user.username}: #{e.message}" + end + end + + { updated: updated, failed: failed, errors: errors } + end + + def sync_projects!(user, repos_data, force_snapshot: false) + current_urls = repos_data.map { |r| r[:github_url] || r[:url] } + user.projects.active.where.not(github_url: current_urls).update_all(archived: true) + + repos_data.each do |repo_data| + url = repo_data[:github_url] || repo_data[:url] + project = user.projects.find_or_initialize_by(github_url: url) + + project.assign_attributes( + name: repo_data[:name], + description: repo_data[:description], + stars: repo_data[:stars].to_i, + forks_count: repo_data[:forks_count].to_i, + size: repo_data[:size].to_i, + topics: repo_data[:topics] || [], + pushed_at: repo_data[:pushed_at].present? ? Time.parse(repo_data[:pushed_at].to_s) : nil, + archived: false + ) + + project.save! + project.record_snapshot!(force: force_snapshot) + end + + visible = user.projects.visible + gained = visible.sum { |p| p.stars_gained } + user.update!( + github_repos_count: visible.count, + github_stars_sum: visible.sum(:stars), + stars_gained: gained + ) + end + + private + + def github_graphql_request(query, api_token, retries: 3) + uri = URI(GITHUB_GRAPHQL_ENDPOINT) + + retries.times do |attempt| + begin + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/json" + request["Authorization"] = "Bearer #{api_token}" + request.body = { query: query }.to_json + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) } + + if response.code == "200" + return JSON.parse(response.body, symbolize_names: true) + elsif %w[502 503 504].include?(response.code) && attempt < retries - 1 + sleep(2 ** (attempt + 1)) + next + else + return { errors: [ "HTTP #{response.code}: #{response.message}" ] } + end + rescue Net::OpenTimeout, Net::ReadTimeout => e + if attempt < retries - 1 + sleep(2 ** (attempt + 1)) + next + else + return { errors: [ "Request timed out: #{e.message}" ] } + end + end + end + end + + def build_batch_query(users) + user_queries = users.each_with_index.map do |user, index| + <<~GRAPHQL + user_#{index}: user(login: "#{user.username}") { + login + email + name + bio + company + websiteUrl + twitterUsername + location + avatarUrl + } + repos_#{index}: search(query: "user:#{user.username} language:Ruby fork:false archived:false sort:updated", type: REPOSITORY, first: 100) { + nodes { + ... on Repository { + name + description + stargazerCount + url + forks { + totalCount + } + diskUsage + pushedAt + repositoryTopics(first: 10) { + nodes { + topic { + name + } + } + } + } + } + } + GRAPHQL + end.join("\n") + + "query { #{user_queries} }" + end + + def update_user_from_graphql(user, profile_data, repos_data) + user.update!( + username: profile_data[:login], + email: profile_data[:email] || user.email, + name: profile_data[:name] || user.name, + bio: profile_data[:bio] || user.bio, + company: profile_data[:company], + website: profile_data[:websiteUrl].presence || user.website, + twitter: profile_data[:twitterUsername].presence || user.twitter, + location: profile_data[:location], + avatar_url: profile_data[:avatarUrl], + github_data_updated_at: Time.current + ) + + repos = repos_data.map do |repo| + { + name: repo[:name], + description: repo[:description], + stars: repo[:stargazerCount], + github_url: repo[:url], + forks_count: repo.dig(:forks, :totalCount) || 0, + size: repo[:diskUsage] || 0, + topics: (repo.dig(:repositoryTopics, :nodes) || []).map { |t| t.dig(:topic, :name) }.compact, + pushed_at: repo[:pushedAt] + } + end + + sync_projects!(user, repos, force_snapshot: true) + end + end + + private + + def fetch_ruby_repositories(github_username, api_token) + uri = URI("https://api.github.com/users/#{github_username}/repos?per_page=100&sort=pushed") + request = Net::HTTP::Get.new(uri) + request["Accept"] = "application/vnd.github.v3+json" + request["Authorization"] = "Bearer #{api_token}" if api_token.present? + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) } + + if response.code == "200" + repos = JSON.parse(response.body) + + repos.select do |repo| + next if repo["fork"] + repo["language"] == "Ruby" || + repo["description"]&.downcase&.include?("ruby") || + repo["name"]&.downcase&.include?("ruby") || + repo["name"]&.downcase&.include?("rails") + end.map do |repo| + { + name: repo["name"], + description: repo["description"], + stars: repo["stargazers_count"], + url: repo["html_url"], + forks_count: repo["forks_count"], + size: repo["size"], + topics: repo["topics"] || [], + pushed_at: repo["pushed_at"] + } + end.sort_by { |r| -r[:stars] } + else + Rails.logger.error "GitHub API returned #{response.code} for #{github_username}: #{response.body}" + [] + end + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 0000000..bca7529 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,5 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :user + attribute :team + attribute :membership +end diff --git a/app/models/language.rb b/app/models/language.rb new file mode 100644 index 0000000..a801d8f --- /dev/null +++ b/app/models/language.rb @@ -0,0 +1,67 @@ +class Language < ApplicationRecord + has_many :team_languages, dependent: :destroy + has_many :teams, through: :team_languages + + validates :code, presence: true, uniqueness: true, + inclusion: { in: ->(_) { Language.available_codes }, message: "has no matching i18n yml file" } + validates :name, presence: true + validates :native_name, presence: true + + scope :enabled, -> { where(enabled: true) } + scope :by_name, -> { order(:name) } + + after_save :bust_caches + + def self.sync_from_locale_files! + Rails.cache.delete("language_available_codes") + codes = available_codes + existing_codes = pluck(:code) + + # Add new languages + added = codes - existing_codes + added.each do |code| + name = I18n.t("language_name", locale: code, default: code.upcase) + native_name = I18n.t("native_name", locale: code, default: code.upcase) + create!(code: code, name: name, native_name: native_name) + end + + removed_codes = existing_codes - codes + where(code: removed_codes).destroy_all if removed_codes.any? + + { added: added, removed: removed_codes } + end + + def self.english + find_by(code: "en") + end + + def self.enabled_codes + Rails.cache.fetch("language_enabled_codes", expires_in: 5.minutes) do + enabled.pluck(:code) + end + end + + def self.available_codes + Rails.cache.fetch("language_available_codes", expires_in: 1.hour) do + locale_path = Rails.root.join("config/locales") + Dir.children(locale_path).filter_map { |entry| + entry.delete_suffix(".yml") if entry.end_with?(".yml") || File.directory?(locale_path.join(entry)) + }.uniq.sort + end + end + + def localized_name + I18n.t("languages.#{code}", default: name) + end + + def english? + code == "en" + end + + private + + def bust_caches + Rails.cache.delete("language_enabled_codes") + Rails.cache.delete("language_available_codes") + end +end diff --git a/app/models/membership.rb b/app/models/membership.rb new file mode 100644 index 0000000..ec00d2a --- /dev/null +++ b/app/models/membership.rb @@ -0,0 +1,22 @@ +class Membership < ApplicationRecord + ROLES = %w[member admin owner].freeze + + belongs_to :user + belongs_to :team + belongs_to :invited_by, class_name: "User", optional: true + + validates :role, inclusion: { in: ROLES } + validates :user_id, uniqueness: { scope: :team_id } + + def owner? + role == "owner" + end + + def admin? + role.in?(%w[admin owner]) + end + + def member? + role == "member" + end +end diff --git a/app/models/message.rb b/app/models/message.rb new file mode 100644 index 0000000..2dc23b6 --- /dev/null +++ b/app/models/message.rb @@ -0,0 +1,96 @@ +class Message < ApplicationRecord + acts_as_message tool_calls_foreign_key: :message_id + has_many_attached :attachments + broadcasts_to ->(message) { "chat_#{message.chat_id}" }, inserts_by: :append, target: "messages" + + after_update_commit :broadcast_message_replacement, if: :assistant? + before_save :calculate_cost, if: :should_calculate_cost? + + # Update counter caches + after_create :increment_counters + after_destroy :decrement_counters + after_update :update_cost_caches, if: :saved_change_to_cost? + + def broadcast_append_chunk(content) + broadcast_append_to "chat_#{chat_id}", + target: "message_#{id}_content", + partial: "messages/content", + locals: { content: content } + end + + def assistant? + role == "assistant" + end + + # Calculate cost based on token usage and model pricing + def calculate_cost + return unless model_pricing.present? + + pricing = model_pricing.dig("text_tokens", "standard") || {} + input_rate = pricing["input_per_million"].to_f + output_rate = pricing["output_per_million"].to_f + cached_rate = pricing["cached_input_per_million"].to_f + + input_cost = (input_tokens.to_i / 1_000_000.0) * input_rate + output_cost = (output_tokens.to_i / 1_000_000.0) * output_rate + cached_cost = (cached_tokens.to_i / 1_000_000.0) * cached_rate + + self.cost = input_cost + output_cost + cached_cost + end + + # Format cost for display (e.g., "$0.0012" or "<$0.0001") + def formatted_cost + return nil if cost.nil? || cost.zero? + + if cost < 0.0001 + "<$0.0001" + else + "$#{'%.4f' % cost}" + end + end + + private + + def increment_counters + return unless chat + + Chat.increment_counter(:messages_count, chat.id) + update_cost_caches + end + + def decrement_counters + return unless chat + + Chat.decrement_counter(:messages_count, chat.id) + chat.recalculate_total_cost! + chat.user&.recalculate_total_cost! + chat.model&.recalculate_total_cost! + end + + def update_cost_caches + return unless chat + + chat.recalculate_total_cost! + chat.user&.recalculate_total_cost! + chat.model&.recalculate_total_cost! + end + + def model_pricing + model&.pricing || chat&.model&.pricing + end + + def should_calculate_cost? + (input_tokens.present? || output_tokens.present?) && cost_changed_from_default? + end + + def cost_changed_from_default? + cost.nil? || cost.zero? + end + + def broadcast_message_replacement + broadcast_replace_to "chat_#{chat_id}", + target: "message_#{id}", + partial: "messages/message", + locals: { message: self } + end +end diff --git a/app/models/model.rb b/app/models/model.rb new file mode 100644 index 0000000..74a2e86 --- /dev/null +++ b/app/models/model.rb @@ -0,0 +1,19 @@ +class Model < ApplicationRecord + include Costable + + acts_as_model chats_foreign_key: :model_id + has_many :chats, foreign_key: :model_id + + scope :enabled, -> { where(provider: configured_providers) } + + # Recalculate total cost from all chats + def recalculate_total_cost! + update_column(:total_cost, chats.sum(:total_cost)) + end + + def self.configured_providers + distinct.pluck(:provider).select do |provider| + Setting.provider_configured?(provider) + end + end +end diff --git a/app/models/post.rb b/app/models/post.rb index 4278815..f548280 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1,4 +1,10 @@ class Post < ApplicationRecord + include Post::SvgSanitizable + include Post::MetadataFetchable + include Post::ImageVariantable + include Post::OgImageGeneratable + include Post::AiSummarizable + extend FriendlyId friendly_id :title, use: [ :slugged, :history, :finders ] @@ -7,7 +13,6 @@ class Post < ApplicationRecord # Constants POST_TYPES = %w[article link success_story].freeze - MAX_IMAGE_SIZE = 20.megabytes # 20MB limit for uploaded images # Associations belongs_to :user @@ -57,7 +62,7 @@ class Post < ApplicationRecord before_validation :clean_logo_svg after_create :generate_summary_job after_update :regenerate_summary_if_needed - after_save :generate_success_story_image, if: -> { success_story? && saved_change_to_logo_svg? } + after_save :enqueue_og_image_generation, if: -> { success_story? && saved_change_to_logo_svg? } after_commit :process_featured_image_if_needed after_update :check_reports_threshold after_create :update_user_counter_caches @@ -101,20 +106,11 @@ def auto_hide_if_needed! end end - - private - def generate_success_story_image - # Force regeneration when logo changes on an existing record - # saved_change_to_logo_svg? returns true if logo_svg changed in the last save - # For new records, we don't need to force (no existing image) - # For existing records with logo changes, we need to force regeneration - force_regenerate = saved_change_to_logo_svg? && !saved_change_to_id? - - Rails.logger.info "GenerateSuccessStoryImageJob triggered for post #{id}: force=#{force_regenerate}, logo_changed=#{saved_change_to_logo_svg?}, new_record=#{saved_change_to_id?}" - - GenerateSuccessStoryImageJob.perform_later(self, force: force_regenerate) + def enqueue_og_image_generation + force = saved_change_to_logo_svg? && !saved_change_to_id? + GenerateSuccessStoryImageJob.perform_later(self, force: force) end def content_or_url_or_logo_present @@ -175,10 +171,7 @@ def set_success_story_category end def clean_logo_svg - return unless logo_svg.present? - - # Sanitize the SVG content to prevent XSS attacks - self.logo_svg = SvgSanitizer.sanitize(logo_svg) + sanitize_logo_svg! end def generate_summary_job @@ -232,94 +225,4 @@ def normalize_url self.url = url.sub(/^http:/, "https:") end end - - def featured_image_validation - return unless featured_image.attached? - - # Check file size - if featured_image.blob.byte_size > MAX_IMAGE_SIZE - errors.add(:featured_image, "is too large (maximum is #{MAX_IMAGE_SIZE / 1.megabyte}MB)") - end - - # Check allowed content types - unless ImageProcessor::ALLOWED_CONTENT_TYPES.include?(featured_image.blob.content_type) - errors.add(:featured_image, "must be a JPEG, PNG, WebP, or TIFF image") - end - end - - def process_featured_image_if_needed - # Check if we have a new image attachment that needs processing - return unless featured_image.attached? - - # Process if: - # 1. No variants exist yet (new upload or migration) - # 2. Featured image was just attached/changed - should_process = !has_processed_images? || - (previous_changes.key?("updated_at") && featured_image.blob.created_at > 1.minute.ago) - - return unless should_process - - Rails.logger.info "Processing image for Post ##{id}" - processor = ImageProcessor.new(featured_image) - result = processor.process! - - if result[:success] - update_columns( - image_variants: result[:variants] - ) - Rails.logger.info "Successfully processed image for Post ##{id}" - else - Rails.logger.error "Failed to process image for Post ##{id}: #{result[:error]}" - end - end - - public - - # Image variant methods (public so they can be used in views/helpers) - def image_variant(size = :medium) - return nil unless featured_image.attached? && image_variants.present? - - variant_id = image_variants[size.to_s] - return featured_image.blob unless variant_id - - ActiveStorage::Blob.find_by(id: variant_id) || featured_image.blob - end - - def image_url_for_size(size = :medium) - blob = image_variant(size) - return nil unless blob - - Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true) - end - - def has_processed_images? - image_variants.present? - end - - def reprocess_image! - return unless featured_image.attached? - - processor = ImageProcessor.new(featured_image) - result = processor.process! - - if result[:success] - update_columns( - image_variants: result[:variants] - ) - end - - result - end - - def clear_image_variants! - # Clear variant blobs if they exist - if image_variants.present? - image_variants.each do |_size, blob_id| - ActiveStorage::Blob.find_by(id: blob_id)&.purge_later - end - end - - # Clear image processing fields - update_columns(image_variants: nil) - end end diff --git a/app/models/price.rb b/app/models/price.rb new file mode 100644 index 0000000..3a0ad7f --- /dev/null +++ b/app/models/price.rb @@ -0,0 +1,59 @@ +class Price + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :id, :string + attribute :product_name, :string + attribute :unit_amount, :integer + attribute :currency, :string + attribute :interval, :string + attribute :interval_count, :integer + + CACHE_KEY = "stripe_prices" + CACHE_DURATION = 1.hour + + class << self + def all + Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_DURATION) do + fetch_from_stripe + end + end + + def find(price_id) + all.find { |p| p.id == price_id } + end + + def clear_cache + Rails.cache.delete(CACHE_KEY) + end + + private + + def fetch_from_stripe + prices = Stripe::Price.list(active: true, type: "recurring", expand: [ "data.product" ]) + prices.data.map do |stripe_price| + new( + id: stripe_price.id, + product_name: stripe_price.product.name, + unit_amount: stripe_price.unit_amount, + currency: stripe_price.currency, + interval: stripe_price.recurring.interval, + interval_count: stripe_price.recurring.interval_count + ) + end + end + end + + def formatted_amount + dollars = unit_amount.to_f / 100 + format("$%.2f", dollars) + end + + def formatted_interval + if interval_count == 1 + "per #{interval}" + else + "every #{interval_count} #{interval}s" + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 6f0f517..83f3f44 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -12,6 +12,12 @@ class Project < ApplicationRecord scope :by_stars, -> { order(stars: :desc) } scope :by_pushed_at, -> { order(Arel.sql("CASE WHEN pushed_at IS NULL THEN 1 ELSE 0 END, pushed_at DESC")) } + # Ensure topics is always an Array (fixtures/raw DB values may be Strings) + def topics + val = super + val.is_a?(Array) ? val : (JSON.parse(val.to_s) rescue []) + end + def stars_gained snapshots = star_snapshots.recent.limit(2).pluck(:stars) return 0 if snapshots.size < 2 diff --git a/app/models/provider_credential.rb b/app/models/provider_credential.rb new file mode 100644 index 0000000..9dcf1da --- /dev/null +++ b/app/models/provider_credential.rb @@ -0,0 +1,64 @@ +class ProviderCredential < ApplicationRecord + CREDENTIAL_SUFFIXES = %w[api_key api_base secret_key region session_token project_id location organization_id].freeze + + validates :provider, presence: true + validates :key, presence: true, uniqueness: { scope: :provider } + + after_save :reconfigure! + after_destroy :reconfigure! + + def self.provider_settings + config_methods = RubyLLM::Configuration.instance_methods(false) + .map(&:to_s) + .reject { |m| m.end_with?("=") } + + providers = {} + config_methods.each do |method| + CREDENTIAL_SUFFIXES.each do |suffix| + next unless method.end_with?("_#{suffix}") + provider = method.delete_suffix("_#{suffix}") + (providers[provider] ||= []) << suffix + break + end + end + + providers.sort.to_h + end + + def self.configured?(provider) + where(provider: provider, key: "api_key").where.not(value: [ nil, "" ]).exists? + end + + def self.configure_ruby_llm! + RubyLLM.configure do |config| + all.each do |cred| + attr = "#{cred.provider}_#{cred.key}" + config.public_send(:"#{attr}=", cred.value) if config.respond_to?(:"#{attr}=") + end + end + rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError + # DB not ready yet + end + + def self.get(provider, key) + find_by(provider: provider, key: key)&.value + end + + def self.set(provider, key, value) + if value.present? + find_or_initialize_by(provider: provider, key: key).update!(value: value) + else + find_by(provider: provider, key: key)&.destroy + end + end + + def secret? + key.end_with?("_key", "_secret", "_token") + end + + private + + def reconfigure! + self.class.configure_ruby_llm! + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb new file mode 100644 index 0000000..ea8f2b3 --- /dev/null +++ b/app/models/setting.rb @@ -0,0 +1,89 @@ +class Setting < ApplicationRecord + DEFAULT_AI_MODEL = "gpt-4.1-nano" + + ALLOWED_KEYS = %i[ + default_ai_model + github_api_token + github_rubycommunity_client_id github_rubycommunity_client_secret + github_whyruby_client_id github_whyruby_client_secret + litestream_replica_access_key litestream_replica_bucket litestream_replica_key_id + mail_from + public_chats + smtp_address smtp_password smtp_username + stripe_publishable_key stripe_secret_key stripe_webhook_secret + summary_model testimonial_model translation_model + trial_days + validation_model + ].freeze + + after_save :reconfigure! + + def self.instance + first || create! + end + + def self.get(key, default: nil) + raise ArgumentError, "Unknown setting: #{key}" unless ALLOWED_KEYS.include?(key.to_sym) + value = instance.public_send(key) + value.presence || default + rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError + default + end + + def self.provider_configured?(provider) + ProviderCredential.configured?(provider) + end + + def self.chats_enabled? + get(:public_chats) != false && Model.configured_providers.any? + end + + def self.reconfigure! + instance.reconfigure! + rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError + # DB not ready yet — skip + end + + def reconfigure! + ProviderCredential.configure_ruby_llm! + configure_default_model! + configure_stripe! + configure_smtp! + configure_litestream! + end + + private + + def configure_default_model! + return unless has_attribute?(:default_ai_model) + model = default_ai_model.presence || DEFAULT_AI_MODEL + RubyLLM.configure { |config| config.default_model = model } + end + + def configure_stripe! + Stripe.api_key = stripe_secret_key + end + + def configure_smtp! + ActionMailer::Base.default_options = { from: mail_from } if has_attribute?(:mail_from) && mail_from.present? + + return unless Rails.env.production? + return if smtp_address.blank? + + ActionMailer::Base.smtp_settings = { + address: smtp_address, + user_name: smtp_username, + password: smtp_password, + port: 587, + authentication: :plain + } + end + + def configure_litestream! + return unless Rails.application.config.respond_to?(:litestream) + + Rails.application.config.litestream.replica_bucket = litestream_replica_bucket + Rails.application.config.litestream.replica_key_id = litestream_replica_key_id + Rails.application.config.litestream.replica_access_key = litestream_replica_access_key + end +end diff --git a/app/models/team.rb b/app/models/team.rb new file mode 100644 index 0000000..30c7d58 --- /dev/null +++ b/app/models/team.rb @@ -0,0 +1,121 @@ +class Team < ApplicationRecord + extend FriendlyId + include Subscribable + + friendly_id :name, use: :slugged + + has_one_attached :logo + + has_many :memberships, dependent: :destroy + has_many :users, through: :memberships + has_many :chats, dependent: :destroy + has_many :team_languages, dependent: :destroy + has_many :languages, through: :team_languages + has_many :articles, dependent: :destroy + + attribute :remove_logo, :boolean, default: false + after_save :purge_logo, if: :remove_logo + + validates :name, presence: true, uniqueness: true + validates :slug, presence: true, uniqueness: true + + before_create :generate_api_key + before_create :start_trial + after_create :setup_default_language + + # Find or create a team for the given company name. + # If company is blank, creates a personal team for the user. + def self.find_or_create_for_user!(user) + company = user.company.presence + + if company + team = Team.find_by(name: company) + unless team + team = Team.create!(name: company) + end + + unless user.member_of?(team) + team.memberships.create!(user: user, role: "member") + end + + team + else + # No company — create a personal team + team = Team.create!(name: "#{user.display_name}'s Team") + team.memberships.create!(user: user, role: "owner") + team + end + end + + def total_chat_cost + chats.sum(:total_cost) + end + + def regenerate_api_key! + update!(api_key: SecureRandom.hex(32)) + end + + def active_language_codes + team_languages.active.joins(:language).pluck("languages.code") + end + + def translation_target_codes(exclude:) + active_language_codes - Array(exclude) + end + + def enable_language!(language) + tl = team_languages.find_or_initialize_by(language: language) + tl.update!(active: true) + tl + end + + def disable_language!(language) + tl = team_languages.find_by(language: language) + tl&.update!(active: false) + tl + end + + def normalize_friendly_id(input) + input.to_s.to_slug.transliterate(:cyrillic).transliterate.normalize.to_s.parameterize + end + + def should_generate_new_friendly_id? + name_changed? || slug.blank? + end + + def resolve_friendly_id_conflict(candidates) + base = candidates.first + separator = friendly_id_config.sequence_separator + counter = 2 + slug = "#{base}#{separator}#{counter}" + scope = self.class.base_class.unscoped.where.not(id: id) + while scope.exists?(slug: slug) + counter += 1 + slug = "#{base}#{separator}#{counter}" + end + slug + end + + private + + def purge_logo + logo.purge_later + end + + def generate_api_key + self.api_key ||= SecureRandom.hex(32) + end + + def start_trial + trial_days = Setting.get(:trial_days) || 30 + return if trial_days.zero? + + self.subscription_status = "trialing" + self.current_period_ends_at = trial_days.days.from_now + end + + def setup_default_language + language = Language.enabled.find_by(code: I18n.locale.to_s) || Language.english + enable_language!(language) if language + end +end diff --git a/app/models/team_language.rb b/app/models/team_language.rb new file mode 100644 index 0000000..e6dce2f --- /dev/null +++ b/app/models/team_language.rb @@ -0,0 +1,8 @@ +class TeamLanguage < ApplicationRecord + belongs_to :team + belongs_to :language + + validates :language_id, uniqueness: { scope: :team_id } + + scope :active, -> { where(active: true) } +end diff --git a/app/models/testimonial.rb b/app/models/testimonial.rb index e947118..78d1d8c 100644 --- a/app/models/testimonial.rb +++ b/app/models/testimonial.rb @@ -1,4 +1,7 @@ class Testimonial < ApplicationRecord + include Testimonial::AiGeneratable + include Testimonial::AiValidatable + belongs_to :user validates :quote, length: { minimum: 140, maximum: 320 }, allow_blank: true @@ -26,7 +29,16 @@ def process_quote_change else # Non-empty quote - process with AI update_columns(ai_attempts: 0, published: false, ai_feedback: nil, reject_reason: nil) - GenerateTestimonialFieldsJob.perform_later(self) + GenerateTestimonialJob.perform_later(self) end end + + def broadcast_testimonial_update + Turbo::StreamsChannel.broadcast_replace_to( + "testimonial_#{id}", + target: "testimonial_section", + partial: "testimonials/section", + locals: { testimonial: self, user: user } + ) + end end diff --git a/app/models/tool_call.rb b/app/models/tool_call.rb new file mode 100644 index 0000000..1ebd927 --- /dev/null +++ b/app/models/tool_call.rb @@ -0,0 +1,3 @@ +class ToolCall < ApplicationRecord + acts_as_tool_call +end diff --git a/app/models/user.rb b/app/models/user.rb index 48fb513..1003011 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,6 +2,10 @@ class User < ApplicationRecord extend FriendlyId friendly_id :username, use: [ :slugged, :history, :finders ] + include Costable if defined?(Costable) + include User::Geocodable + include User::GithubSyncable + # Associations has_many :posts, dependent: :destroy has_many :comments, dependent: :destroy @@ -12,6 +16,11 @@ class User < ApplicationRecord has_many :projects, dependent: :destroy has_many :visible_projects, -> { visible.by_pushed_at }, class_name: "Project" has_many :hidden_projects, -> { hidden.active.by_pushed_at }, class_name: "Project" + has_many :chats, dependent: :destroy + has_many :memberships, dependent: :destroy + has_many :teams, through: :memberships + + has_one_attached :avatar # Enums enum :role, { member: 0, admin: 1 } @@ -22,18 +31,23 @@ class User < ApplicationRecord # Callbacks before_save :precompute_bio_html, if: :will_save_change_to_bio? after_save :enqueue_location_normalization, if: :saved_change_to_location? + after_save :purge_avatar, if: :remove_avatar after_commit :invalidate_map_cache, if: -> { saved_change_to_latitude? || saved_change_to_longitude? || saved_change_to_public? || saved_change_to_avatar_url? || saved_change_to_open_to_work? } + attribute :remove_avatar, :boolean, default: false + # Validations validates :github_id, presence: true, uniqueness: true validates :username, presence: true, uniqueness: true validates :slug, uniqueness: true, allow_blank: true validates :email, uniqueness: true, allow_blank: true + before_validation :nilify_blank_locale + # Scopes scope :trusted, -> { where("published_posts_count >= ? AND published_comments_count >= ?", 3, 10) @@ -47,9 +61,6 @@ class User < ApplicationRecord where("normalized_location LIKE ? OR normalized_location = ?", "%, #{country_code}", country_code) } - # Devise modules for GitHub OAuth - devise :omniauthable, omniauth_providers: [ :github ] - # Instance methods def trusted? published_posts_count >= 3 && published_comments_count >= 10 @@ -59,6 +70,12 @@ def can_report? trusted? end + def onboarded? = name.present? + + def effective_locale(fallback: :en) + locale&.to_sym || fallback + end + def visible_ruby_repositories visible_projects.to_a end @@ -174,19 +191,11 @@ def self.from_omniauth(auth) end # Fetch and update GitHub data on every sign in - GithubDataFetcher.new(user, auth).fetch_and_update! + user.sync_github_data_from_oauth!(auth) user end - def self.new_with_session(params, session) - super.tap do |user| - if data = session["devise.github_data"] && session["devise.github_data"]["extra"]["raw_info"] - user.email = data["email"] if user.email.blank? - end - end - end - # Cross-domain session sync methods def generate_cross_domain_token! token = SecureRandom.urlsafe_base64(32) @@ -209,6 +218,28 @@ def self.authenticate_cross_domain_token(token) user end + # Team membership methods + def membership_for(team) + memberships.find_by(team: team) + end + + def member_of?(team) + memberships.exists?(team: team) + end + + def admin_of?(team) + memberships.exists?(team: team, role: %w[admin owner]) + end + + def owner_of?(team) + memberships.exists?(team: team, role: "owner") + end + + # Recalculate total cost from all chats + def recalculate_total_cost! + update_column(:total_cost, chats.sum(:total_cost)) + end + # Linkify URLs and GitHub @mentions in bio text # Returns precomputed HTML for display def self.linkify_bio(text) @@ -226,7 +257,7 @@ def self.linkify_bio(text) (?:www\.)? # Optional www [a-zA-Z0-9][a-zA-Z0-9\-]* # Domain name \.[a-zA-Z]{2,} # TLD - (?:/[^\s,.<>]*)? # Optional path + (?:/[^\s,<>]*[^\s,.<>])? # Optional path (allow dots mid-path, not trailing) }x # Replace GitHub @mentions first @@ -264,4 +295,12 @@ def enqueue_location_normalization def invalidate_map_cache Rails.cache.delete("community_map_data") end + + def purge_avatar + avatar.purge_later + end + + def nilify_blank_locale + self.locale = nil if locale.blank? + end end diff --git a/app/resources/application_resource.rb b/app/resources/application_resource.rb new file mode 100644 index 0000000..0ee11ba --- /dev/null +++ b/app/resources/application_resource.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class ApplicationResource < ActionResource::Base + # Shared helpers for all MCP resources + # + # Note: Resources in fast-mcp don't receive request headers, + # so authentication isn't possible for resources. + # Resources should either be public or use tools for authenticated access. + + private + + # Resources cannot authenticate - always returns nil + # Use tools for authenticated operations + def current_user + nil + end + + # Check if user is authenticated (always false for resources) + def authenticated? + false + end + + # Convert data to JSON string + def to_json(data) + data.to_json + end + + # Format timestamps consistently + def format_timestamp(time) + time&.iso8601 + end +end diff --git a/app/resources/mcp/articles_resource.rb b/app/resources/mcp/articles_resource.rb new file mode 100644 index 0000000..2be7780 --- /dev/null +++ b/app/resources/mcp/articles_resource.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Mcp + class ArticlesResource < ApplicationResource + uri "app:///articles" + resource_name "Articles" + description "Team's articles. Use list_articles tool for authenticated access." + mime_type "application/json" + + def content + to_json({ + message: "Use the 'list_articles' tool for authenticated article list", + tool: "list_articles" + }) + end + end +end diff --git a/app/resources/mcp/available_languages_resource.rb b/app/resources/mcp/available_languages_resource.rb new file mode 100644 index 0000000..b204afc --- /dev/null +++ b/app/resources/mcp/available_languages_resource.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mcp + class AvailableLanguagesResource < ApplicationResource + uri "app:///languages" + resource_name "Available Languages" + description "List of all enabled languages available for translation" + mime_type "application/json" + + def content + languages = Language.enabled.by_name + + to_json({ + languages_count: languages.size, + languages: languages.map { |l| + { + id: l.id, + code: l.code, + name: l.name, + native_name: l.native_name + } + } + }) + end + end +end diff --git a/app/resources/mcp/available_models_resource.rb b/app/resources/mcp/available_models_resource.rb new file mode 100644 index 0000000..7046090 --- /dev/null +++ b/app/resources/mcp/available_models_resource.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Mcp + class AvailableModelsResource < ApplicationResource + uri "app:///models" + resource_name "Available Models" + description "List of all AI models available for use" + mime_type "application/json" + + def content + models = Model.enabled.order(:provider, :name) + + to_json({ + models_count: models.count, + providers: Model.configured_providers, + models: models.map { |model| serialize_model(model) } + }) + end + + private + + def serialize_model(model) + { + id: model.id, + model_id: model.model_id, + name: model.name, + provider: model.provider, + family: model.family, + context_window: model.context_window, + max_output_tokens: model.max_output_tokens, + capabilities: model.capabilities, + pricing: model.pricing + } + end + end +end diff --git a/app/resources/mcp/chat_messages_resource.rb b/app/resources/mcp/chat_messages_resource.rb new file mode 100644 index 0000000..69be9e7 --- /dev/null +++ b/app/resources/mcp/chat_messages_resource.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mcp + class ChatMessagesResource < ApplicationResource + uri "app:///chats/{chat_id}/messages" + resource_name "Chat Messages" + description "Messages in a specific chat. Use list_messages tool for authenticated access." + mime_type "application/json" + + def content(chat_id:) + # Resources don't receive auth headers in fast-mcp + # Direct users to use the tool instead + to_json({ + message: "Use the 'list_messages' tool for authenticated message access", + tool: "list_messages", + arguments: { chat_id: chat_id } + }) + end + end +end diff --git a/app/resources/mcp/chat_resource.rb b/app/resources/mcp/chat_resource.rb new file mode 100644 index 0000000..a368a7b --- /dev/null +++ b/app/resources/mcp/chat_resource.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mcp + class ChatResource < ApplicationResource + uri "app:///chats/{id}" + resource_name "Chat" + description "A single chat conversation with all messages. Use show_chat tool for authenticated access." + mime_type "application/json" + + def content(id:) + # Resources don't receive auth headers in fast-mcp + # Direct users to use the tool instead + to_json({ + message: "Use the 'show_chat' tool for authenticated chat access", + tool: "show_chat", + arguments: { id: id } + }) + end + end +end diff --git a/app/resources/mcp/current_user_resource.rb b/app/resources/mcp/current_user_resource.rb new file mode 100644 index 0000000..779fdad --- /dev/null +++ b/app/resources/mcp/current_user_resource.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Mcp + class CurrentUserResource < ApplicationResource + uri "app:///user/current" + resource_name "Current User" + description "Information about the currently authenticated user. Use show_current_user tool for authenticated access." + mime_type "application/json" + + def content + # Resources don't receive auth headers in fast-mcp + # Direct users to use the tool instead + to_json({ + message: "Use the 'show_current_user' tool for authenticated user info", + tool: "show_current_user" + }) + end + end +end diff --git a/app/resources/mcp/team_languages_resource.rb b/app/resources/mcp/team_languages_resource.rb new file mode 100644 index 0000000..c588590 --- /dev/null +++ b/app/resources/mcp/team_languages_resource.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Mcp + class TeamLanguagesResource < ApplicationResource + uri "app:///team/languages" + resource_name "Team Languages" + description "Team's active languages. Use list_team_languages tool for authenticated access." + mime_type "application/json" + + def content + to_json({ + message: "Use the 'list_team_languages' tool for authenticated team language list", + tool: "list_team_languages" + }) + end + end +end diff --git a/app/resources/mcp/team_subscription_resource.rb b/app/resources/mcp/team_subscription_resource.rb new file mode 100644 index 0000000..e258e9e --- /dev/null +++ b/app/resources/mcp/team_subscription_resource.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Mcp + class TeamSubscriptionResource < ApplicationResource + uri "app:///subscription" + resource_name "Team Subscription" + description "Team subscription status. Use show_subscription tool for authenticated access." + mime_type "application/json" + + def content + to_json({ + message: "Use the 'show_subscription' tool for authenticated subscription info", + tool: "show_subscription" + }) + end + end +end diff --git a/app/resources/mcp/user_chats_resource.rb b/app/resources/mcp/user_chats_resource.rb new file mode 100644 index 0000000..556e453 --- /dev/null +++ b/app/resources/mcp/user_chats_resource.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Mcp + class UserChatsResource < ApplicationResource + uri "app:///chats" + resource_name "User Chats" + description "List of all chats for the authenticated user. Use list_chats tool for authenticated access." + mime_type "application/json" + + def content + # Resources don't receive auth headers in fast-mcp + # Direct users to use the tool instead + to_json({ + message: "Use the 'list_chats' tool for authenticated chat list", + tool: "list_chats" + }) + end + end +end diff --git a/app/services/github_data_fetcher.rb b/app/services/github_data_fetcher.rb deleted file mode 100644 index 4d662f7..0000000 --- a/app/services/github_data_fetcher.rb +++ /dev/null @@ -1,364 +0,0 @@ -class GithubDataFetcher - GRAPHQL_ENDPOINT = "https://api.github.com/graphql" - - attr_reader :user, :auth_data, :api_token - - # Can be initialized with either OAuth auth_data (for sign-in) or without it (for scheduled updates) - def initialize(user, auth_data = nil) - @user = user - @auth_data = auth_data - @api_token = auth_data&.credentials&.token || Rails.application.credentials.dig(:github, :api_token) - end - - # === Class Methods for Batch GraphQL Fetching === - - # Main batch method - fetches and updates multiple users in a single GraphQL request - # Automatically splits batch on transient errors (502/503/504) - def self.batch_fetch_and_update!(users, api_token: nil) - api_token ||= Rails.application.credentials.dig(:github, :api_token) - return { updated: 0, failed: users.size, errors: [ "No API token configured" ] } unless api_token.present? - - users_with_usernames = users.select { |u| u.username.present? } - return { updated: 0, failed: 0, errors: [] } if users_with_usernames.empty? - - query = build_batch_query(users_with_usernames) - response = graphql_request(query, api_token, retries: 2) - - # On transient errors, split batch in half and retry recursively - if response[:errors].present? && response[:data].nil? - error_msg = response[:errors].first.to_s - if error_msg.match?(/50[234]/) && users_with_usernames.size > 1 - Rails.logger.warn "Batch of #{users_with_usernames.size} failed with #{error_msg}, splitting in half..." - mid = users_with_usernames.size / 2 - first_half = batch_fetch_and_update!(users_with_usernames[0...mid], api_token: api_token) - sleep(1) # Brief pause between split batches - second_half = batch_fetch_and_update!(users_with_usernames[mid..], api_token: api_token) - - return { - updated: first_half[:updated] + second_half[:updated], - failed: first_half[:failed] + second_half[:failed], - errors: first_half[:errors] + second_half[:errors] - } - end - - return { updated: 0, failed: users_with_usernames.size, errors: response[:errors] } - end - - updated = 0 - failed = 0 - errors = [] - - users_with_usernames.each_with_index do |user, index| - user_key = :"user_#{index}" - repos_key = :"repos_#{index}" - user_data = response.dig(:data, user_key) - repos_data = response.dig(:data, repos_key, :nodes) - - if user_data.nil? - failed += 1 - errors << "User #{user.username} not found on GitHub" - next - end - - begin - update_user_from_graphql(user, user_data, repos_data || []) - updated += 1 - rescue => e - failed += 1 - errors << "Failed to update #{user.username}: #{e.message}" - Rails.logger.error "GraphQL batch update error for #{user.username}: #{e.message}" - end - end - - { updated: updated, failed: failed, errors: errors } - end - - # Execute a GraphQL request with retry for transient errors - def self.graphql_request(query, api_token, retries: 3) - require "net/http" - require "json" - - uri = URI(GRAPHQL_ENDPOINT) - - retries.times do |attempt| - begin - request = Net::HTTP::Post.new(uri) - request["Content-Type"] = "application/json" - request["Authorization"] = "Bearer #{api_token}" - request.body = { query: query }.to_json - - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| - http.request(request) - end - - if response.code == "200" - return JSON.parse(response.body, symbolize_names: true) - elsif %w[502 503 504].include?(response.code) && attempt < retries - 1 - sleep_time = 2 ** (attempt + 1) # Exponential backoff: 2, 4, 8 seconds - Rails.logger.warn "GraphQL request got #{response.code}, retrying in #{sleep_time}s (attempt #{attempt + 1}/#{retries})" - sleep(sleep_time) - next - else - Rails.logger.error "GraphQL request failed: #{response.code} - #{response.body}" - return { errors: [ "HTTP #{response.code}: #{response.message}" ] } - end - rescue Net::OpenTimeout, Net::ReadTimeout => e - if attempt < retries - 1 - sleep_time = 2 ** (attempt + 1) - Rails.logger.warn "GraphQL request timed out, retrying in #{sleep_time}s (attempt #{attempt + 1}/#{retries})" - sleep(sleep_time) - next - else - Rails.logger.error "GraphQL request timed out after #{retries} attempts: #{e.message}" - return { errors: [ "Request timed out: #{e.message}" ] } - end - end - end - end - - # Build a batched GraphQL query for multiple users using aliases - # Uses search query to filter by language:Ruby server-side - def self.build_batch_query(users) - user_queries = users.each_with_index.map do |user, index| - <<~GRAPHQL - user_#{index}: user(login: "#{user.username}") { - login - email - name - bio - company - websiteUrl - twitterUsername - location - avatarUrl - } - repos_#{index}: search(query: "user:#{user.username} language:Ruby fork:false archived:false sort:updated", type: REPOSITORY, first: 100) { - nodes { - ... on Repository { - name - description - stargazerCount - url - forks { - totalCount - } - diskUsage - pushedAt - repositoryTopics(first: 10) { - nodes { - topic { - name - } - } - } - } - } - } - GRAPHQL - end.join("\n") - - "query { #{user_queries} }" - end - - # Update a user from GraphQL response data - def self.update_user_from_graphql(user, profile_data, repos_data) - # Update profile fields - user.update!( - username: profile_data[:login], - email: profile_data[:email] || user.email, - name: profile_data[:name] || user.name, - bio: profile_data[:bio] || user.bio, - company: profile_data[:company], - website: profile_data[:websiteUrl].presence || user.website, - twitter: profile_data[:twitterUsername].presence || user.twitter, - location: profile_data[:location], - avatar_url: profile_data[:avatarUrl], - github_data_updated_at: Time.current - ) - - # Process repositories into Project records - repos = repos_data.map do |repo| - { - name: repo[:name], - description: repo[:description], - stars: repo[:stargazerCount], - github_url: repo[:url], - forks_count: repo.dig(:forks, :totalCount) || 0, - size: repo[:diskUsage] || 0, - topics: (repo.dig(:repositoryTopics, :nodes) || []).map { |t| t.dig(:topic, :name) }.compact, - pushed_at: repo[:pushedAt] - } - end - - sync_projects!(user, repos, force_snapshot: true) - end - - # Sync GitHub repos to Project records with star snapshot tracking - def self.sync_projects!(user, repos_data, force_snapshot: false) - current_urls = repos_data.map { |r| r[:github_url] || r[:url] } - - # Soft-archive projects no longer returned by GitHub - user.projects.active.where.not(github_url: current_urls).update_all(archived: true) - - repos_data.each do |repo_data| - url = repo_data[:github_url] || repo_data[:url] - project = user.projects.find_or_initialize_by(github_url: url) - - project.assign_attributes( - name: repo_data[:name], - description: repo_data[:description], - stars: repo_data[:stars].to_i, - forks_count: repo_data[:forks_count].to_i, - size: repo_data[:size].to_i, - topics: repo_data[:topics] || [], - pushed_at: repo_data[:pushed_at].present? ? Time.parse(repo_data[:pushed_at].to_s) : nil, - archived: false - ) - - project.save! - project.record_snapshot!(force: force_snapshot) - end - - # Recalculate cached stats on user - visible = user.projects.visible - gained = visible.sum { |p| p.stars_gained } - user.update!( - github_repos_count: visible.count, - github_stars_sum: visible.sum(:stars), - stars_gained: gained - ) - end - - # === Instance Methods for OAuth Sign-in (unchanged) === - - def fetch_and_update! - update_basic_profile - fetch_and_store_repositories - user.update!(github_data_updated_at: Time.current) - end - - private - - def update_basic_profile - if auth_data&.extra&.raw_info - # Use OAuth data if available (during sign-in) - update_from_oauth_data - else - # Fetch from GitHub API (for scheduled updates) - update_from_api - end - end - - def update_from_oauth_data - raw_info = auth_data.extra.raw_info - - user.update!( - username: auth_data.info.nickname, - email: auth_data.info.email, - name: raw_info.name || user.name, - bio: raw_info.bio || user.bio, - company: raw_info.company, - website: raw_info.blog.presence || user.website, - twitter: raw_info.twitter_username.presence || user.twitter, - location: raw_info.location, - avatar_url: auth_data.info.image - ) - end - - def update_from_api - require "net/http" - require "json" - - return unless user.username.present? - - uri = URI("https://api.github.com/users/#{user.username}") - request = Net::HTTP::Get.new(uri) - request["Accept"] = "application/vnd.github.v3+json" - request["Authorization"] = "Bearer #{api_token}" if api_token.present? - - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| - http.request(request) - end - - if response.code == "200" - data = JSON.parse(response.body) - - user.update!( - username: data["login"], - email: data["email"] || user.email, - name: data["name"] || user.name, - bio: data["bio"] || user.bio, - company: data["company"], - website: data["blog"].presence || user.website, - twitter: data["twitter_username"] || user.twitter, - location: data["location"], - avatar_url: data["avatar_url"] - ) - else - Rails.logger.error "Failed to fetch GitHub profile for #{user.username}: #{response.code} - #{response.message}" - raise "GitHub API error: #{response.code}" unless response.code == "404" - end - end - - def fetch_and_store_repositories - github_username = auth_data&.info&.nickname || user.username - return unless github_username.present? - - repos = fetch_ruby_repositories(github_username) - if repos.present? - # Normalize key: REST API uses :url, sync_projects! expects :github_url - repos.each { |r| r[:github_url] ||= r.delete(:url) } - self.class.sync_projects!(user, repos) - end - rescue => e - Rails.logger.error "Failed to fetch GitHub repositories for #{github_username}: #{e.message}" - end - - def fetch_ruby_repositories(username) - require "net/http" - require "json" - - uri = URI("https://api.github.com/users/#{username}/repos?per_page=100&sort=pushed") - - request = Net::HTTP::Get.new(uri) - request["Accept"] = "application/vnd.github.v3+json" - request["Authorization"] = "Bearer #{api_token}" if api_token.present? - - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| - http.request(request) - end - - if response.code == "200" - repos = JSON.parse(response.body) - - # Filter for Ruby repositories, excluding forks - ruby_repos = repos.select do |repo| - # Skip forked repositories - we only want original work - next if repo["fork"] - - # Check if it's a Ruby-related repository - repo["language"] == "Ruby" || - repo["description"]&.downcase&.include?("ruby") || - repo["name"]&.downcase&.include?("ruby") || - repo["name"]&.downcase&.include?("rails") - end.map do |repo| - # Only store fields we actually display on the user's page - { - name: repo["name"], - description: repo["description"], - stars: repo["stargazers_count"], - url: repo["html_url"], - forks_count: repo["forks_count"], - size: repo["size"], # Size in KB - topics: repo["topics"] || [], - pushed_at: repo["pushed_at"] - } - end.sort_by { |r| -r[:stars] } # Sort by stars descending - - ruby_repos - else - Rails.logger.error "GitHub API returned #{response.code} for #{username}: #{response.body}" - [] - end - end -end diff --git a/app/services/image_processor.rb b/app/services/image_processor.rb deleted file mode 100644 index 4cab66c..0000000 --- a/app/services/image_processor.rb +++ /dev/null @@ -1,146 +0,0 @@ -# Service for processing uploaded images into multiple WebP variants -# Uses ImageMagick directly for compatibility with server constraints -class ImageProcessor - ALLOWED_CONTENT_TYPES = %w[ - image/jpeg - image/jpg - image/png - image/webp - image/tiff - image/x-tiff - ].freeze - - # Variant dimensions (width x height) - VARIANTS = { - tile: { width: 684, height: 384, quality: 92 }, # For tiles/grids (2x for retina) - post: { width: 1664, height: 936, quality: 94 }, # For post detail pages (2x for retina) - og: { width: 1200, height: 630, quality: 95 } # Open Graph meta image (exact dimensions) - }.freeze - - def initialize(blob_or_attachment) - @blob = blob_or_attachment.is_a?(ActiveStorage::Blob) ? blob_or_attachment : blob_or_attachment.blob - end - - def process! - return { error: "File too large (max #{Post::MAX_IMAGE_SIZE / 1.megabyte}MB)" } if @blob.byte_size > Post::MAX_IMAGE_SIZE - return { error: "Invalid file type" } unless ALLOWED_CONTENT_TYPES.include?(@blob.content_type) - - variants = {} - - @blob.open do |tempfile| - # Generate each variant - VARIANTS.each do |name, config| - variant_blob = generate_variant(tempfile.path, config) - variants[name] = variant_blob.id if variant_blob - end - end - - { - success: true, - variants: variants - } - rescue => e - Rails.logger.error "ImageProcessor error: #{e.message}" - { error: "Processing failed: #{e.message}" } - end - - # Process an image from a URL (for metadata fetching) - def self.process_from_url(url, post) - return unless url.present? - - require "open-uri" - - begin - image_io = URI.open(url, - "User-Agent" => "Ruby/#{RUBY_VERSION}", - read_timeout: 10, - open_timeout: 10 - ) - - # Check file size before processing - if image_io.size > Post::MAX_IMAGE_SIZE - Rails.logger.warn "Image from URL too large: #{image_io.size} bytes (max #{Post::MAX_IMAGE_SIZE} bytes)" - return nil - end - - # Create a temporary file for processing - temp_file = Tempfile.new([ "remote_image", File.extname(URI.parse(url).path) ]) - temp_file.binmode - temp_file.write(image_io.read) - temp_file.rewind - - # Process the image - process_and_attach_image(temp_file, post, File.basename(URI.parse(url).path)) - - rescue => e - Rails.logger.error "Failed to fetch/process image from URL #{url}: #{e.message}" - nil - ensure - temp_file&.close - temp_file&.unlink - end - end - - private - - - - def generate_variant(source_path, config) - variant_file = Tempfile.new([ "variant", ".webp" ]) - - begin - # Resize and convert to WebP with high quality - cmd = [ - "convert", - source_path, - "-resize", "#{config[:width]}x#{config[:height]}>", # Only shrink larger images - "-filter", "Lanczos", # Best quality resize filter - "-quality", config[:quality].to_s, - "-define", "webp:lossless=false", # Use lossy for smaller size - "-define", "webp:method=6", # Best quality (slower) - "-define", "webp:alpha-quality=100", # Preserve alpha quality - "-define", "webp:image-hint=photo", # Optimize for photos - "-strip", # Remove metadata - "webp:#{variant_file.path}" - ] - - unless system(*cmd, err: File::NULL) - Rails.logger.error "Failed to generate variant: #{config.inspect}" - return nil - end - - # Create a new blob for this variant - ActiveStorage::Blob.create_and_upload!( - io: File.open(variant_file.path), - filename: "variant_#{config[:width]}x#{config[:height]}.webp", - content_type: "image/webp" - ) - - ensure - variant_file.close - variant_file.unlink - end - end - - def self.process_and_attach_image(file, post, filename = "image") - # Keep original filename for now, will be converted to WebP during processing - - # First attach the original - post.featured_image.attach( - io: file, - filename: filename - ) - - # Process variants - processor = new(post.featured_image) - result = processor.process! - - if result[:success] - post.update_columns( - image_variants: result[:variants] - ) - end - - result - end -end diff --git a/app/services/location_normalizer.rb b/app/services/location_normalizer.rb deleted file mode 100644 index 52da1b3..0000000 --- a/app/services/location_normalizer.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -require "net/http" -require "json" - -class LocationNormalizer - PHOTON_API = "https://photon.komoot.io/api/" - - GeoResult = Data.define(:city, :state, :country_code, :latitude, :longitude) - - def self.normalize(raw_location) - new.normalize(raw_location) - end - - def normalize(raw_location) - return nil if raw_location.blank? - - # Skip strings that are clearly not geographic (pure emoji, etc.) - stripped = raw_location.gsub(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/, "").strip - return nil if stripped.empty? - - result = photon_search(raw_location) - return nil unless result - - normalized_string = build_normalized_string(result) - return nil unless normalized_string - - { - normalized_location: normalized_string, - latitude: result.latitude, - longitude: result.longitude - } - end - - private - - def photon_search(query) - uri = URI(PHOTON_API) - uri.query = URI.encode_www_form(q: query, limit: 1) - - response = Net::HTTP.get_response(uri) - return nil unless response.is_a?(Net::HTTPSuccess) - - data = JSON.parse(response.body) - feature = data.dig("features", 0) - return nil unless feature - - properties = feature["properties"] - return nil unless properties - - coordinates = feature.dig("geometry", "coordinates") - lon, lat = coordinates if coordinates.is_a?(Array) && coordinates.size >= 2 - - GeoResult.new( - city: properties["city"] || (properties["type"] == "city" ? properties["name"] : nil), - state: properties["state"], - country_code: properties["countrycode"], - latitude: lat&.to_f, - longitude: lon&.to_f - ) - rescue StandardError => e - Rails.logger.warn "Photon geocoding failed: #{e.message}" - nil - end - - def build_normalized_string(result) - city = result.city - country_code = result.country_code&.upcase - - return nil unless country_code.present? - - if city.present? - "#{city}, #{country_code}" - else - state = result.state - if state.present? - "#{state}, #{country_code}" - else - country_code - end - end - end -end diff --git a/app/services/metadata_fetcher.rb b/app/services/metadata_fetcher.rb deleted file mode 100644 index c227673..0000000 --- a/app/services/metadata_fetcher.rb +++ /dev/null @@ -1,213 +0,0 @@ -class MetadataFetcher - require "net/http" - require "nokogiri" - - MAX_REDIRECTS = 3 - DEFAULT_TIMEOUT = 10 - DEFAULT_RETRIES = 1 - - attr_reader :url, :options, :parsed - - def initialize(url, options = {}) - @url = url - @options = options - @connection_timeout = options[:connection_timeout] || DEFAULT_TIMEOUT - @read_timeout = options[:read_timeout] || DEFAULT_TIMEOUT - @retries = options[:retries] || DEFAULT_RETRIES - @allow_redirections = options[:allow_redirections] - @parsed = nil - end - - def fetch! - html = fetch_html_with_retries - return {} unless html - - @parsed = Nokogiri::HTML(html) - - { - title: best_title, - description: best_description, - image_url: best_image, - parsed: @parsed - } - rescue => e - Rails.logger.error "Failed to fetch metadata from #{@url}: #{e.message}" - {} - end - - def best_title - extract_meta(property: "og:title") || - extract_meta(name: "twitter:title") || - @parsed&.at_css("title")&.text&.strip || - @parsed&.at_css("h1")&.text&.strip - end - - def best_description - extract_meta(property: "og:description") || - extract_meta(name: "twitter:description") || - extract_meta(name: "description") || - extract_first_paragraph - end - - def best_image - og_image = extract_meta(property: "og:image") - return resolve_url(og_image) if og_image - - twitter_image = extract_meta(name: "twitter:image") - return resolve_url(twitter_image) if twitter_image - - largest_image = find_largest_image - return resolve_url(largest_image) if largest_image - - first_image = @parsed&.at_css("img")&.[]("src") - resolve_url(first_image) if first_image - end - - private - - def fetch_html_with_retries - attempts = 0 - begin - attempts += 1 - fetch_html(@url) - rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT => e - if attempts <= @retries - Rails.logger.warn "Timeout fetching #{@url}, retrying (#{attempts}/#{@retries}): #{e.message}" - retry - else - Rails.logger.error "Failed to fetch #{@url} after #{@retries} retries: #{e.message}" - nil - end - rescue => e - Rails.logger.error "Error fetching #{@url}: #{e.message}" - nil - end - end - - def fetch_html(url, redirect_count = 0) - uri = URI(url) - - # Validate URL scheme - unless %w[http https].include?(uri.scheme&.downcase) - Rails.logger.error "Invalid URL scheme: #{uri.scheme}" - return nil - end - - request = Net::HTTP::Get.new(uri) - request["User-Agent"] = "Ruby/#{RUBY_VERSION} (WhyRuby.info)" - request["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" - - response = Net::HTTP.start( - uri.hostname, - uri.port, - use_ssl: uri.scheme == "https", - open_timeout: @connection_timeout, - read_timeout: @read_timeout - ) do |http| - http.request(request) - end - - case response - when Net::HTTPSuccess - response.body - when Net::HTTPRedirection - handle_redirect(response, uri, redirect_count) - else - Rails.logger.error "HTTP error fetching #{url}: #{response.code} - #{response.message}" - nil - end - end - - def handle_redirect(response, current_uri, redirect_count) - if redirect_count >= MAX_REDIRECTS - Rails.logger.error "Too many redirects (#{redirect_count}) for #{@url}" - return nil - end - - location = response["location"] - return nil unless location - - # Resolve relative redirects - redirect_uri = URI.join(current_uri.to_s, location) - - # Check if redirect is safe - if @allow_redirections == :safe && !safe_redirect?(current_uri, redirect_uri) - Rails.logger.error "Unsafe redirect from #{current_uri} to #{redirect_uri}" - return nil - end - - fetch_html(redirect_uri.to_s, redirect_count + 1) - end - - def safe_redirect?(current_uri, redirect_uri) - # Only allow HTTPS redirects or same-origin HTTP - redirect_uri.scheme == "https" || - (redirect_uri.scheme == "http" && redirect_uri.host == current_uri.host) - end - - def extract_meta(property: nil, name: nil) - if property - @parsed&.at_css("meta[property='#{property}']")&.[]("content")&.strip - elsif name - @parsed&.at_css("meta[name='#{name}']")&.[]("content")&.strip - end - end - - def extract_first_paragraph - paragraphs = @parsed&.css("p") - return nil unless paragraphs - - paragraphs.each do |p| - text = p.text.strip - # Return first paragraph with substantial content - return text if text.length > 50 - end - - nil - end - - def find_largest_image - images = @parsed&.css("img") - return nil unless images&.any? - - largest = nil - max_size = 0 - - images.each do |img| - width = img["width"].to_i - height = img["height"].to_i - - # Skip if dimensions not specified - next if width.zero? || height.zero? - - # Skip tiny images (likely icons/logos) - next if width < 200 || height < 200 - - # Skip images with weird aspect ratios - aspect_ratio = width.to_f / height - next if aspect_ratio < 0.33 || aspect_ratio > 3.0 - - size = width * height - if size > max_size - max_size = size - largest = img["src"] - end - end - - largest - end - - def resolve_url(path) - return nil if path.blank? - return path if path.start_with?("http://", "https://") - - # Resolve relative URLs - begin - base_uri = URI(@url) - URI.join(base_uri, path).to_s - rescue => e - Rails.logger.warn "Failed to resolve relative URL #{path}: #{e.message}" - path - end - end -end diff --git a/app/services/success_story_image_generator.rb b/app/services/success_story_image_generator.rb deleted file mode 100644 index dbdfc65..0000000 --- a/app/services/success_story_image_generator.rb +++ /dev/null @@ -1,265 +0,0 @@ -class SuccessStoryImageGenerator - # Template should be 1200x630 WebP format - TEMPLATE_PATH = Rails.root.join("app", "assets", "images", "success_story_template.webp") - - # OG Image dimensions - OG_WIDTH = 1200 - OG_HEIGHT = 630 - - # Logo positioning (centered in the template) - LOGO_MAX_WIDTH = 410 # Maximum width for logo - LOGO_MAX_HEIGHT = 190 # Maximum height for logo - LOGO_CENTER_X = 410 # Center X position (1200/2) - LOGO_CENTER_Y = 145 # Center Y position (630/2) - - # NOTE: This service uses ImageMagick's 'convert' command directly for v6 compatibility - # No Ruby gems required, just ImageMagick binary installed on the system - - def initialize(post) - @post = post - end - - def generate! - return unless @post.success_story? && @post.logo_svg.present? - - # Check if ImageMagick is available - unless system("which convert > /dev/null 2>&1") - Rails.logger.error "ImageMagick 'convert' command not found" - return nil - end - - # Convert SVG logo to WebP and overlay on template - webp_data = generate_social_image - - # Store the generated WebP in ActiveStorage - if webp_data && !webp_data.empty? - @post.featured_image.attach( - io: StringIO.new(webp_data), - filename: "#{@post.slug}-social.webp", - content_type: "image/webp" - ) - - # Also process variants for the success story image - if @post.featured_image.attached? - processor = ImageProcessor.new(@post.featured_image) - result = processor.process! - - if result[:success] - @post.update_columns( - image_variants: result[:variants] - ) - end - end - end - end - - private - - def generate_social_image - # Create temp files - svg_file = Tempfile.new([ "logo", ".svg" ]) - logo_file = Tempfile.new([ "logo_converted", ".webp" ]) - output_file = Tempfile.new([ "success_story", ".webp" ]) - - begin - # Write SVG to file - svg_file.write(@post.logo_svg) - svg_file.rewind - - # Check if template exists - unless File.exist?(TEMPLATE_PATH) - Rails.logger.error "Template file not found for success story image generation: #{TEMPLATE_PATH}" - return nil - end - - # Dual approach: Try rsvg-convert first (better SVG handling), fall back to ImageMagick - converted = false - - # Method 1: Try rsvg-convert (preferred - no black border issues) - if system("which", "rsvg-convert", out: File::NULL, err: File::NULL) - Rails.logger.info "Using rsvg-convert for SVG conversion" - - # First pass: Convert at high resolution for quality - temp_high_res = Tempfile.new([ "high_res", ".png" ]) - rsvg_cmd = [ - "rsvg-convert", - "--keep-aspect-ratio", - "--width", (LOGO_MAX_WIDTH * 2).to_s, # 2x for better quality - "--height", (LOGO_MAX_HEIGHT * 2).to_s, - "--background-color", "transparent", - svg_file.path, - "--output", temp_high_res.path - ] - - if system(*rsvg_cmd, err: File::NULL) - # Second pass: Convert to WebP and resize to fit within bounds - resize_cmd = [ - "convert", - temp_high_res.path, - "-resize", "#{LOGO_MAX_WIDTH}x#{LOGO_MAX_HEIGHT}>", # Shrink to fit - "-filter", "Lanczos", - "-quality", "95", - "-background", "none", - "-gravity", "center", - "-define", "webp:method=6", - "-define", "webp:alpha-quality=100", - "webp:#{logo_file.path}" - ] - - if system(*resize_cmd) - converted = true - Rails.logger.info "Successfully converted SVG using rsvg-convert" - end - end - - temp_high_res.close - temp_high_res.unlink - end - - # Method 2: Fall back to ImageMagick - unless converted - Rails.logger.info "Using ImageMagick for SVG conversion" - - svg_to_webp_cmd = [ - "convert", - "-background", "none", - "-density", "300", # Higher density for better quality - svg_file.path, - "-resize", "#{LOGO_MAX_WIDTH}x#{LOGO_MAX_HEIGHT}>", # Shrink to fit - "-filter", "Lanczos", - "-quality", "95", - "-gravity", "center", - "-define", "webp:method=6", - "-define", "webp:alpha-quality=100", - "webp:#{logo_file.path}" - ] - - unless system(*svg_to_webp_cmd) - Rails.logger.error "Failed to convert SVG to WebP with both methods" - return nil - end - end - - # Get dimensions of the converted logo - require "open3" - stdout, status = Open3.capture2("identify", "-format", "%wx%h", logo_file.path) - unless status.success? - Rails.logger.error "Failed to get image dimensions" - return nil - end - dimensions = stdout.strip - logo_width, logo_height = dimensions.split("x").map(&:to_i) - - # Calculate position to center logo on template - x_offset = LOGO_CENTER_X - (logo_width / 2) - y_offset = LOGO_CENTER_Y - (logo_height / 2) - - # Composite logo onto template using ImageMagick - composite_cmd = [ - "convert", - TEMPLATE_PATH.to_s, - logo_file.path, - "-geometry", "+#{x_offset}+#{y_offset}", - "-composite", - "-quality", "95", - "-define", "webp:method=4", - "webp:#{output_file.path}" - ] - - unless system(*composite_cmd) - Rails.logger.error "Failed to composite images" - return nil - end - - # Read and return raw WebP data - File.read(output_file.path) - - rescue => e - Rails.logger.error "Failed to generate success story image: #{e.message}" - nil - ensure - svg_file.close - svg_file.unlink - logo_file.close - logo_file.unlink - output_file.close - output_file.unlink - end - end - - # Alternative method to just convert SVG to WebP without template - def svg_to_webp - svg_file = Tempfile.new([ "logo", ".svg" ]) - webp_file = Tempfile.new([ "logo", ".webp" ]) - - begin - svg_file.write(@post.logo_svg) - svg_file.rewind - - # Dual approach for simple conversion too - converted = false - - # Try rsvg-convert first - if system("which", "rsvg-convert", out: File::NULL, err: File::NULL) - # First convert to PNG with rsvg-convert - temp_png = Tempfile.new([ "temp", ".png" ]) - rsvg_cmd = [ - "rsvg-convert", - "--keep-aspect-ratio", - "--background-color", "transparent", - svg_file.path, - "--output", temp_png.path - ] - - if system(*rsvg_cmd, err: File::NULL) - # Then convert PNG to WebP - png_to_webp_cmd = [ - "convert", - temp_png.path, - "-quality", "95", - "-define", "webp:method=6", - "-define", "webp:alpha-quality=100", - "webp:#{webp_file.path}" - ] - - if system(*png_to_webp_cmd) - converted = true - end - end - - temp_png.close - temp_png.unlink - end - - # Fall back to direct ImageMagick conversion - unless converted - convert_cmd = [ - "convert", - "-background", "none", - "-density", "300", - svg_file.path, - "-quality", "95", - "-define", "webp:method=6", - "-define", "webp:alpha-quality=100", - "webp:#{webp_file.path}" - ] - - unless system(*convert_cmd) - Rails.logger.error "Failed to convert SVG to WebP" - return nil - end - end - - # Return raw WebP data - File.read(webp_file.path) - rescue => e - Rails.logger.error "Failed to convert SVG to WebP: #{e.message}" - nil - ensure - svg_file.close - svg_file.unlink - webp_file.close - webp_file.unlink - end - end -end diff --git a/app/services/svg_sanitizer.rb b/app/services/svg_sanitizer.rb deleted file mode 100644 index 1f838c8..0000000 --- a/app/services/svg_sanitizer.rb +++ /dev/null @@ -1,255 +0,0 @@ -class SvgSanitizer - # This sanitizer ensures SVGs are safe and cross-platform compatible - # - # CRITICAL: SVG Case Sensitivity - # 1. SVG attributes are case-sensitive (viewBox, not viewbox) - # 2. Linux strictly enforces this, macOS is forgiving - # 3. We use Nokogiri::XML (not HTML) to preserve case - # - # We still fix case before parsing to handle any incoming - # SVGs with incorrect case, then XML parser preserves it. - - # Allowed SVG elements - ALLOWED_ELEMENTS = %w[ - svg g path rect circle ellipse line polyline polygon text tspan textPath - defs pattern clipPath mask linearGradient radialGradient stop symbol use - image desc title metadata - ].freeze - - # Allowed attributes (no event handlers) - # Note: These should be lowercase for comparison - # width and height are intentionally excluded to allow proper responsive scaling - ALLOWED_ATTRIBUTES = %w[ - style class - viewbox preserveaspectratio - x y x1 y1 x2 y2 cx cy r rx ry - d points fill stroke stroke-width stroke-linecap stroke-linejoin - fill-opacity stroke-opacity opacity - transform translate rotate scale - font-family font-size font-weight text-anchor - href xlink:href - offset stop-color stop-opacity - gradientunits gradienttransform - patternunits patterntransform - clip-path mask - xmlns xmlns:xlink version - ].map(&:downcase).freeze - - # Dangerous patterns to remove - DANGEROUS_PATTERNS = [ - /<script[\s>]/i, - /<\/script>/i, - /javascript:/i, - /on\w+\s*=/i, # Event handlers like onclick, onload, etc. - /data:text\/html/i, - /vbscript:/i, - /behavior:/i, - /expression\(/i, - /-moz-binding:/i - ].freeze - - # Fix common SVG case sensitivity issues - # Many SVG editors output incorrect case for attributes, which breaks on Linux - def self.fix_svg_case_sensitivity(svg_content) - return svg_content if svg_content.blank? - - fixed = svg_content.dup - - # Most common problematic attributes - fixed.gsub!(/\bviewbox=/i, "viewBox=") - fixed.gsub!(/\bpreserveaspectratio=/i, "preserveAspectRatio=") - - # Gradient-related attributes - fixed.gsub!(/\bgradientunits=/i, "gradientUnits=") - fixed.gsub!(/\bgradienttransform=/i, "gradientTransform=") - - # Pattern-related attributes - fixed.gsub!(/\bpatternunits=/i, "patternUnits=") - fixed.gsub!(/\bpatterntransform=/i, "patternTransform=") - - # Other common camelCase attributes - fixed.gsub!(/\bclippath=/i, "clipPath=") - fixed.gsub!(/\btextlength=/i, "textLength=") - fixed.gsub!(/\blengthadjust=/i, "lengthAdjust=") - fixed.gsub!(/\bbaseprofile=/i, "baseProfile=") - - # Marker-related attributes - fixed.gsub!(/\bmarkerwidth=/i, "markerWidth=") - fixed.gsub!(/\bmarkerheight=/i, "markerHeight=") - fixed.gsub!(/\bmarkerunits=/i, "markerUnits=") - - # Reference attributes - fixed.gsub!(/\brefx=/i, "refX=") - fixed.gsub!(/\brefy=/i, "refY=") - - # Path and stroke attributes - fixed.gsub!(/\bpathlength=/i, "pathLength=") - fixed.gsub!(/\bstrokedasharray=/i, "strokeDasharray=") - fixed.gsub!(/\bstrokedashoffset=/i, "strokeDashoffset=") - fixed.gsub!(/\bstrokelinecap=/i, "strokeLinecap=") - fixed.gsub!(/\bstrokelinejoin=/i, "strokeLinejoin=") - fixed.gsub!(/\bstrokemiterlimit=/i, "strokeMiterlimit=") - - fixed - end - - # Fix viewBox positioning issues - # Some SVGs have offset viewBox values (e.g., "0 302.1 612 192") that cause content - # to render outside the visible area. We normalize these to start at 0,0. - # - # NOTE: This is conservative - only fixes when offset is likely problematic: - # - Y offset > height (content completely above visible area) - # - X offset > width (content completely to the left of visible area) - def self.fix_viewbox_offset(svg_content) - return svg_content if svg_content.blank? - - # Match viewBox attribute - if svg_content =~ /viewBox\s*=\s*["']([^"']+)["']/i - viewbox_value = $1 - values = viewbox_value.split(/\s+/).map(&:to_f) - - if values.length == 4 - x_offset, y_offset, width, height = values - - # Only fix if offset seems problematic (content likely outside visible area) - # This preserves intentional offsets for sprites, artistic crops, etc. - if y_offset > height || x_offset > width || y_offset > 100 - # Create new viewBox starting at 0,0 - new_viewbox = "0 0 #{width} #{height}" - - # Replace the viewBox - fixed_svg = svg_content.gsub(/viewBox\s*=\s*["'][^"']+["']/i, "viewBox=\"#{new_viewbox}\"") - - # Add a transform to the SVG content to compensate for the offset - # This moves all content up/left by the offset amount - if x_offset != 0 || y_offset != 0 - # Add transform to the first <svg> tag - fixed_svg = fixed_svg.sub(/<svg([^>]*)>/i) do |match| - attrs = $1 - # Check if there's already a transform - if attrs =~ /transform\s*=/i - # Prepend to existing transform - attrs.sub!(/transform\s*=\s*["']([^"']+)["']/i) do |t| - "transform=\"translate(#{-x_offset} #{-y_offset}) #{$1}\"" - end - "<svg#{attrs}>" - else - # Add new transform - "<svg#{attrs} transform=\"translate(#{-x_offset} #{-y_offset})\">" - end - end - end - - return fixed_svg - end - end - end - - svg_content - end - - def self.sanitize(svg_content) - return "" if svg_content.blank? - - # Fix common SVG case sensitivity issues FIRST - # Many SVG editors output incorrect case for attributes, which breaks on Linux - svg_content = fix_svg_case_sensitivity(svg_content) - - # NOTE: We DON'T automatically fix viewBox offsets as they may be intentional for: - # - Icon sprites/atlases (showing specific regions) - # - Artistic cropping - # - Animation preparation - # - Print bleeds - # - Technical diagrams with specific coordinate systems - # Uncomment the line below only if you're having issues with offset viewBoxes: - # svg_content = fix_viewbox_offset(svg_content) - - # Remove any dangerous patterns - DANGEROUS_PATTERNS.each do |pattern| - svg_content = svg_content.gsub(pattern, "") - end - - # Parse the SVG - use XML parsing since SVG is XML! - # This preserves attribute case (viewBox stays viewBox) - begin - # Parse with NOENT disabled to prevent XXE attacks (though Nokogiri does this by default) - doc = Nokogiri::XML::DocumentFragment.parse(svg_content) do |config| - config.nonet # Disable network connections - config.noent # Disable entity substitution - end - rescue => e - Rails.logger.error "Failed to parse SVG: #{e.message}" - return "" - end - - # Find SVG element - in XML mode, it's a direct child - svg_element = if doc.children.any? { |c| c.name.downcase == "svg" } - doc.children.find { |c| c.name.downcase == "svg" } - else - # Fallback to CSS selector for nested SVGs - doc.at_css("svg") || doc.at_xpath("//svg") - end - - return "" unless svg_element - - # Process all elements within the SVG - svg_element.css("*").each do |element| - unless ALLOWED_ELEMENTS.include?(element.name.downcase) - element.remove - next - end - - # Remove all attributes that aren't in our allowlist - element.attributes.keys.each do |name| - unless ALLOWED_ATTRIBUTES.include?(name.downcase) - element.remove_attribute(name) - end - end - - # Additional check for style attribute - if element["style"] - # Remove any dangerous CSS properties - style = element["style"] - if style =~ /javascript:|expression\(|behavior:|binding:|@import/i - element.remove_attribute("style") - end - end - - # Check href attributes for javascript: protocol - %w[href xlink:href].each do |attr| - if element[attr] && element[attr] =~ /^javascript:/i - element.remove_attribute(attr) - end - end - end - - # Store original dimensions before cleaning for viewBox calculation - original_width = svg_element["width"] - original_height = svg_element["height"] - - # Also clean the SVG element itself - svg_element.attributes.keys.each do |name| - unless ALLOWED_ATTRIBUTES.include?(name.downcase) - svg_element.remove_attribute(name) - end - end - - # Ensure SVG has a viewBox for proper scaling - # If no viewBox exists but we had width/height, create one - if svg_element["viewBox"].blank? && svg_element["viewbox"].blank? - if original_width && original_height - # Extract numeric values from width/height (remove px, %, etc) - width_val = original_width.to_s.gsub(/[^\d.]/, "").to_f - height_val = original_height.to_s.gsub(/[^\d.]/, "").to_f - - if width_val > 0 && height_val > 0 - svg_element["viewBox"] = "0 0 #{width_val} #{height_val}" - end - end - end - - # Return the cleaned SVG - # Using XML parser preserves case, so no need to fix again - svg_element.to_xml - end -end diff --git a/app/services/timezone_resolver.rb b/app/services/timezone_resolver.rb deleted file mode 100644 index 4ae68e8..0000000 --- a/app/services/timezone_resolver.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class TimezoneResolver - # Legacy IANA identifiers renamed in recent tzdata releases. - # The wheretz gem still returns old names that newer system - # zoneinfo packages no longer include. - LEGACY_IDENTIFIERS = { - "Europe/Kiev" => "Europe/Kyiv" - }.freeze - - def self.resolve(latitude, longitude) - new.resolve(latitude, longitude) - end - - def self.normalize(timezone) - return "Etc/UTC" if timezone.blank? - - normalized = LEGACY_IDENTIFIERS[timezone] || timezone - TZInfo::Timezone.get(normalized) - normalized - rescue TZInfo::InvalidTimezoneIdentifier - Rails.logger.warn "Unknown timezone identifier: #{timezone}, falling back to Etc/UTC" - "Etc/UTC" - end - - def resolve(latitude, longitude) - return "Etc/UTC" if latitude.nil? || longitude.nil? - - result = WhereTZ.lookup(latitude, longitude) - timezone = result.is_a?(Array) ? result.first : result - self.class.normalize(timezone) - rescue StandardError => e - Rails.logger.warn "Timezone lookup failed for (#{latitude}, #{longitude}): #{e.message}" - "Etc/UTC" - end -end diff --git a/app/tools/application_tool.rb b/app/tools/application_tool.rb new file mode 100644 index 0000000..375ea7e --- /dev/null +++ b/app/tools/application_tool.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +class ApplicationTool < ActionTool::Base + # Authentication helpers available to all tools + # Override in subclasses to customize authorization behavior + # + # Authentication flow: + # 1. x-api-key header → find Team + # 2. x-user-email header (optional) → find User, verify team membership + # + # Tools that only need team context use require_team! + # Tools that need user context use require_user! or with_current_user + + class << self + # Mark a tool as requiring admin privileges + def admin_only! + @admin_only = true + end + + def admin_only? + @admin_only == true + end + end + + private + + # Primary auth: Team from API key + def current_team + return @current_team if defined?(@current_team) + + api_key = headers["x-api-key"] + @current_team = Team.find_by(api_key: api_key) if api_key.present? + end + + # Secondary: User from email header (must be team member) + def current_user + return @current_user if defined?(@current_user) + return @current_user = nil unless current_team + + email = headers["x-user-email"] + if email.present? + user = User.find_by(email: email) + @current_user = user if user&.member_of?(current_team) + end + @current_user + end + + # Get current admin from session (for browser-based requests) + # Note: Session-based admin auth may not work via MCP - use API keys instead + def current_admin + return @current_admin if defined?(@current_admin) + + # Admin auth via MCP would need a separate mechanism + # For now, this is a placeholder + @current_admin = nil + end + + # Execute block with Current.user and Current.team set for model callbacks + def with_current_user + require_user! + previous_user = Current.user + previous_team = Current.team + Current.user = current_user + Current.team = current_team + yield + ensure + Current.user = previous_user + Current.team = previous_team + end + + # Check if team is authenticated via API key + def team_authenticated? + current_team.present? + end + + # Check if user is authenticated (team + user email) + def authenticated? + current_user.present? + end + + # Check if admin is authenticated + def admin_authenticated? + current_admin.present? + end + + # Require team authentication - raise if no valid API key + def require_team! + unless team_authenticated? + raise FastMcp::Tool::InvalidArgumentsError, "Valid x-api-key header required." + end + end + + # Require user authentication - raise if no valid user + def require_user! + require_team! + unless authenticated? + raise FastMcp::Tool::InvalidArgumentsError, "x-user-email header required (must be team member)." + end + end + + # Legacy alias for backwards compatibility + def require_authentication! + require_user! + end + + # Require admin authentication - raise if not authenticated + def require_admin! + raise FastMcp::Tool::InvalidArgumentsError, "Admin authentication required." unless admin_authenticated? + end + + # Standard success response format + def success_response(data, message: nil) + response = { success: true, data: data } + response[:message] = message if message + response + end + + # Standard error response format + def error_response(message, code: nil) + response = { success: false, error: message } + response[:code] = code if code + response + end + + # Format timestamps consistently + def format_timestamp(time) + time&.iso8601 + end +end diff --git a/app/tools/articles/create_article_tool.rb b/app/tools/articles/create_article_tool.rb new file mode 100644 index 0000000..8ff4425 --- /dev/null +++ b/app/tools/articles/create_article_tool.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Articles + class CreateArticleTool < ApplicationTool + description "Create a new article" + + annotations( + title: "Create Article", + read_only_hint: false, + open_world_hint: false + ) + + arguments do + required(:title).filled(:string).description("Article title") + optional(:body).filled(:string).description("Article body") + end + + def call(title:, body: nil) + with_current_user do + article = current_team.articles.create!( + user: current_user, + title: title, + body: body, + ) + + success_response( + { id: article.id, title: article.title }, + message: "Article created" + ) + end + end + end +end diff --git a/app/tools/articles/delete_article_tool.rb b/app/tools/articles/delete_article_tool.rb new file mode 100644 index 0000000..8352016 --- /dev/null +++ b/app/tools/articles/delete_article_tool.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Articles + class DeleteArticleTool < ApplicationTool + description "Delete an article" + + annotations( + title: "Delete Article", + read_only_hint: false, + open_world_hint: false + ) + + arguments do + required(:id).filled(:string).description("Article ID") + end + + def call(id:) + require_user! + + article = current_team.articles.find_by(id: id) + return error_response("Article not found") unless article + + article.destroy! + + success_response({ id: id }, message: "Article deleted") + end + end +end diff --git a/app/tools/articles/list_articles_tool.rb b/app/tools/articles/list_articles_tool.rb new file mode 100644 index 0000000..0f7be4b --- /dev/null +++ b/app/tools/articles/list_articles_tool.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Articles + class ListArticlesTool < ApplicationTool + description "List articles for the current team" + + annotations( + title: "List Articles", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + optional(:limit).filled(:integer).description("Max articles to return") + end + + def call(limit: 20) + require_user! + + articles = current_team.articles.includes(:user).recent.limit(limit) + + success_response(articles.map { |a| serialize_article(a) }) + end + + private + + def serialize_article(article) + { + id: article.id, + title: article.title, + body: article.body&.truncate(200), + author: article.user.name, + created_at: format_timestamp(article.created_at) + } + end + end +end diff --git a/app/tools/articles/show_article_tool.rb b/app/tools/articles/show_article_tool.rb new file mode 100644 index 0000000..37ca62d --- /dev/null +++ b/app/tools/articles/show_article_tool.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Articles + class ShowArticleTool < ApplicationTool + description "Get a specific article with full content" + + annotations( + title: "Show Article", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + required(:id).filled(:string).description("Article ID") + optional(:locale).filled(:string).description("Locale code for translated content (e.g., 'es')") + end + + def call(id:, locale: nil) + require_user! + + article = current_team.articles.find_by(id: id) + return error_response("Article not found") unless article + + data = if locale + Mobility.with_locale(locale) do + serialize_article(article).merge(locale: locale) + end + else + serialize_article(article) + end + + success_response(data) + end + + private + + def serialize_article(article) + { + id: article.id, + title: article.title, + body: article.body, + author_id: article.user_id, + created_at: format_timestamp(article.created_at), + updated_at: format_timestamp(article.updated_at) + } + end + end +end diff --git a/app/tools/articles/update_article_tool.rb b/app/tools/articles/update_article_tool.rb new file mode 100644 index 0000000..bd83aa4 --- /dev/null +++ b/app/tools/articles/update_article_tool.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Articles + class UpdateArticleTool < ApplicationTool + description "Update an existing article" + + annotations( + title: "Update Article", + read_only_hint: false, + open_world_hint: false + ) + + arguments do + required(:id).filled(:string).description("Article ID") + optional(:title).filled(:string).description("New title") + optional(:body).filled(:string).description("New body") + end + + def call(id:, title: nil, body: nil) + require_user! + + article = current_team.articles.find_by(id: id) + return error_response("Article not found") unless article + + updates = {} + updates[:title] = title if title + updates[:body] = body if body + + article.update!(updates) if updates.any? + + success_response( + { id: article.id, title: article.title }, + message: "Article updated" + ) + end + end +end diff --git a/app/tools/billing/cancel_subscription_tool.rb b/app/tools/billing/cancel_subscription_tool.rb new file mode 100644 index 0000000..6e61f49 --- /dev/null +++ b/app/tools/billing/cancel_subscription_tool.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Billing + class CancelSubscriptionTool < ApplicationTool + description "Cancel team subscription at end of billing period (admin only)" + + annotations( + title: "Cancel Subscription", + read_only_hint: false, + open_world_hint: true + ) + + def call + require_user! + + membership = current_user.membership_for(current_team) + unless membership&.admin? + return error_response("Admin access required", code: "forbidden") + end + + unless current_team.stripe_subscription_id.present? + return error_response("No active subscription to cancel", code: "not_found") + end + + current_team.cancel_subscription! + + success_response({ + subscription_status: current_team.subscription_status, + cancel_at_period_end: current_team.cancel_at_period_end, + current_period_ends_at: format_timestamp(current_team.current_period_ends_at) + }, message: "Subscription will be canceled at end of billing period") + end + end +end diff --git a/app/tools/billing/create_checkout_tool.rb b/app/tools/billing/create_checkout_tool.rb new file mode 100644 index 0000000..d860208 --- /dev/null +++ b/app/tools/billing/create_checkout_tool.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Billing + class CreateCheckoutTool < ApplicationTool + description "Create a Stripe Checkout session for subscription (admin only)" + + annotations( + title: "Create Checkout", + read_only_hint: false, + open_world_hint: true + ) + + arguments do + required(:price_id).filled(:string).description("Stripe price ID to subscribe to") + required(:success_url).filled(:string).description("URL to redirect after successful checkout") + required(:cancel_url).filled(:string).description("URL to redirect if checkout is canceled") + end + + def call(price_id:, success_url:, cancel_url:) + require_user! + + membership = current_user.membership_for(current_team) + unless membership&.admin? + return error_response("Admin access required", code: "forbidden") + end + + session = current_team.create_checkout_session( + price_id: price_id, + success_url: success_url, + cancel_url: cancel_url + ) + + success_response({ + checkout_url: session.url, + session_id: session.id + }) + end + end +end diff --git a/app/tools/billing/get_billing_portal_tool.rb b/app/tools/billing/get_billing_portal_tool.rb new file mode 100644 index 0000000..9c29678 --- /dev/null +++ b/app/tools/billing/get_billing_portal_tool.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Billing + class GetBillingPortalTool < ApplicationTool + description "Get a Stripe Billing Portal URL to manage subscription (admin only)" + + annotations( + title: "Get Billing Portal", + read_only_hint: true, + open_world_hint: true + ) + + arguments do + required(:return_url).filled(:string).description("URL to return to after managing billing") + end + + def call(return_url:) + require_user! + + membership = current_user.membership_for(current_team) + unless membership&.admin? + return error_response("Admin access required", code: "forbidden") + end + + unless current_team.stripe_customer_id.present? + return error_response("No billing account found. Subscribe to a plan first.", code: "not_found") + end + + portal = current_team.create_billing_portal_session(return_url: return_url) + + success_response({ + portal_url: portal.url + }) + end + end +end diff --git a/app/tools/billing/list_prices_tool.rb b/app/tools/billing/list_prices_tool.rb new file mode 100644 index 0000000..60c64bd --- /dev/null +++ b/app/tools/billing/list_prices_tool.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Billing + class ListPricesTool < ApplicationTool + description "List available subscription prices" + + annotations( + title: "List Prices", + read_only_hint: true, + open_world_hint: false + ) + + def call + prices = Price.all + + success_response(prices.map { |p| + { + id: p.id, + product_name: p.product_name, + amount: p.formatted_amount, + interval: p.formatted_interval, + currency: p.currency + } + }) + end + end +end diff --git a/app/tools/billing/resume_subscription_tool.rb b/app/tools/billing/resume_subscription_tool.rb new file mode 100644 index 0000000..725adc1 --- /dev/null +++ b/app/tools/billing/resume_subscription_tool.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Billing + class ResumeSubscriptionTool < ApplicationTool + description "Resume a canceled subscription before the billing period ends (admin only)" + + annotations( + title: "Resume Subscription", + read_only_hint: false, + open_world_hint: true + ) + + def call + require_user! + + membership = current_user.membership_for(current_team) + unless membership&.admin? + return error_response("Admin access required", code: "forbidden") + end + + unless current_team.cancellation_pending? + return error_response("No pending cancellation to resume", code: "not_found") + end + + current_team.resume_subscription! + + success_response({ + subscription_status: current_team.subscription_status, + cancel_at_period_end: current_team.cancel_at_period_end, + current_period_ends_at: format_timestamp(current_team.current_period_ends_at) + }, message: "Subscription resumed successfully") + end + end +end diff --git a/app/tools/billing/show_subscription_tool.rb b/app/tools/billing/show_subscription_tool.rb new file mode 100644 index 0000000..f198b32 --- /dev/null +++ b/app/tools/billing/show_subscription_tool.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Billing + class ShowSubscriptionTool < ApplicationTool + description "Get the current team's subscription status (admin only)" + + annotations( + title: "Show Subscription", + read_only_hint: true, + open_world_hint: false + ) + + def call + require_user! + + membership = current_user.membership_for(current_team) + unless membership&.admin? + return error_response("Admin access required", code: "forbidden") + end + + success_response({ + team_id: current_team.id, + team_name: current_team.name, + subscription_status: current_team.subscription_status, + subscribed: current_team.subscribed?, + cancel_at_period_end: current_team.cancel_at_period_end, + cancellation_pending: current_team.cancellation_pending?, + current_period_ends_at: format_timestamp(current_team.current_period_ends_at), + stripe_customer_id: current_team.stripe_customer_id + }) + end + end +end diff --git a/app/tools/chats/create_chat_tool.rb b/app/tools/chats/create_chat_tool.rb new file mode 100644 index 0000000..07031d5 --- /dev/null +++ b/app/tools/chats/create_chat_tool.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Chats + class CreateChatTool < ApplicationTool + description "Create a new chat conversation" + + annotations( + title: "Create Chat", + read_only_hint: false, + open_world_hint: false + ) + + arguments do + required(:model_id).filled(:string).description("The ID of the model to use for this chat") + optional(:initial_message).filled(:string).description("Optional initial message to send") + end + + def call(model_id:, initial_message: nil) + require_user! + + model = Model.enabled.find_by(id: model_id) + return error_response("Model not found or not enabled", code: "invalid_model") unless model + + chat = nil + with_current_user do + chat = current_user.chats.create!(model: model, team: current_team) + + if initial_message.present? + chat.ask(initial_message) + end + end + + success_response( + { + id: chat.id, + model_id: chat.model_id, + model_name: chat.model&.name, + messages_count: chat.messages_count, + created_at: format_timestamp(chat.created_at) + }, + message: "Chat created successfully" + ) + rescue ActiveRecord::RecordInvalid => e + error_response(e.message, code: "validation_error") + end + end +end diff --git a/app/tools/chats/delete_chat_tool.rb b/app/tools/chats/delete_chat_tool.rb new file mode 100644 index 0000000..3e84d4b --- /dev/null +++ b/app/tools/chats/delete_chat_tool.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Chats + class DeleteChatTool < ApplicationTool + description "Delete a chat and all its messages" + + annotations( + title: "Delete Chat", + read_only_hint: false, + destructive_hint: true, + open_world_hint: false + ) + + arguments do + required(:id).filled(:string).description("The chat ID to delete") + end + + def call(id:) + require_user! + + chat = current_user.chats.where(team: current_team).find_by(id: id) + return error_response("Chat not found", code: "not_found") unless chat + + with_current_user do + chat.destroy! + end + + success_response( + { id: id }, + message: "Chat deleted successfully" + ) + end + end +end diff --git a/app/tools/chats/list_chats_tool.rb b/app/tools/chats/list_chats_tool.rb new file mode 100644 index 0000000..aa0e216 --- /dev/null +++ b/app/tools/chats/list_chats_tool.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Chats + class ListChatsTool < ApplicationTool + description "List all chats for the authenticated user" + + annotations( + title: "List Chats", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + optional(:limit).filled(:integer).description("Maximum number of chats to return (default: 20)") + optional(:offset).filled(:integer).description("Number of chats to skip (default: 0)") + optional(:order).filled(:string).description("Sort order: 'recent' (default) or 'oldest'") + end + + def call(limit: 20, offset: 0, order: "recent") + require_user! + + chats = current_user.chats.where(team: current_team) + chats = order == "oldest" ? chats.chronologically : chats.recent + chats = chats.includes(:model).offset(offset).limit(limit) + + success_response( + chats.map { |chat| serialize_chat(chat) }, + message: "Found #{chats.size} chats" + ) + end + + private + + def serialize_chat(chat) + { + id: chat.id, + model_id: chat.model_id, + model_name: chat.model&.name, + messages_count: chat.messages_count, + total_cost: chat.total_cost.to_f, + created_at: format_timestamp(chat.created_at), + updated_at: format_timestamp(chat.updated_at) + } + end + end +end diff --git a/app/tools/chats/show_chat_tool.rb b/app/tools/chats/show_chat_tool.rb new file mode 100644 index 0000000..5cb4336 --- /dev/null +++ b/app/tools/chats/show_chat_tool.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Chats + class ShowChatTool < ApplicationTool + description "Get details of a specific chat, optionally including messages" + + annotations( + title: "Show Chat", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + required(:id).filled(:string).description("The chat ID") + optional(:include_messages).filled(:bool).description("Include messages in response (default: true)") + end + + def call(id:, include_messages: true) + require_user! + + chat = current_user.chats.where(team: current_team).find_by(id: id) + return error_response("Chat not found", code: "not_found") unless chat + + success_response(serialize_chat(chat, include_messages: include_messages)) + end + + private + + def serialize_chat(chat, include_messages:) + data = { + id: chat.id, + model_id: chat.model_id, + model_name: chat.model&.name, + messages_count: chat.messages_count, + total_cost: chat.total_cost.to_f, + created_at: format_timestamp(chat.created_at), + updated_at: format_timestamp(chat.updated_at) + } + + if include_messages + data[:messages] = chat.messages.order(:created_at).map { |msg| serialize_message(msg) } + end + + data + end + + def serialize_message(message) + { + id: message.id, + role: message.role, + content: message.content, + model_id: message.model_id, + input_tokens: message.input_tokens, + output_tokens: message.output_tokens, + cost: message.cost&.to_f, + created_at: format_timestamp(message.created_at) + } + end + end +end diff --git a/app/tools/chats/update_chat_tool.rb b/app/tools/chats/update_chat_tool.rb new file mode 100644 index 0000000..78c9514 --- /dev/null +++ b/app/tools/chats/update_chat_tool.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Chats + class UpdateChatTool < ApplicationTool + description "Update a chat's model" + + annotations( + title: "Update Chat", + read_only_hint: false, + open_world_hint: false + ) + + arguments do + required(:id).filled(:string).description("The chat ID") + required(:model_id).filled(:string).description("The new model ID to use") + end + + def call(id:, model_id:) + require_user! + + chat = current_user.chats.where(team: current_team).find_by(id: id) + return error_response("Chat not found", code: "not_found") unless chat + + model = Model.enabled.find_by(id: model_id) + return error_response("Model not found or not enabled", code: "invalid_model") unless model + + with_current_user do + chat.with_model(model.model_id) + end + + success_response( + { + id: chat.id, + model_id: chat.model_id, + model_name: chat.model&.name, + updated_at: format_timestamp(chat.updated_at) + }, + message: "Chat model updated successfully" + ) + rescue ActiveRecord::RecordInvalid => e + error_response(e.message, code: "validation_error") + rescue RubyLLM::ConfigurationError => e + error_response(e.message, code: "provider_not_configured") + end + end +end diff --git a/app/tools/languages/add_team_language_tool.rb b/app/tools/languages/add_team_language_tool.rb new file mode 100644 index 0000000..abb5504 --- /dev/null +++ b/app/tools/languages/add_team_language_tool.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Languages + class AddTeamLanguageTool < ApplicationTool + description "Add a language to the team for automatic content translation" + + annotations( + title: "Add Team Language", + read_only_hint: false, + open_world_hint: false + ) + + arguments do + required(:language_code).filled(:string).description("ISO 639-1 language code (e.g., 'es', 'fr')") + end + + def call(language_code:) + require_user! + + membership = current_user.membership_for(current_team) + unless membership&.admin? + return error_response("Admin access required") + end + + language = Language.enabled.find_by(code: language_code) + return error_response("Language not found or not enabled: #{language_code}") unless language + + current_team.enable_language!(language) + BackfillTranslationsJob.perform_later(current_team.id, language.code) + + success_response( + { code: language.code, name: language.name, native_name: language.native_name }, + message: "#{language.name} added. Existing content is being translated." + ) + end + end +end diff --git a/app/tools/languages/list_languages_tool.rb b/app/tools/languages/list_languages_tool.rb new file mode 100644 index 0000000..59ea0b2 --- /dev/null +++ b/app/tools/languages/list_languages_tool.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Languages + class ListLanguagesTool < ApplicationTool + description "List all enabled languages available for translation" + + annotations( + title: "List Languages", + read_only_hint: true, + open_world_hint: false + ) + + def call + languages = Language.enabled.by_name + + success_response(languages.map { |l| + { + id: l.id, + code: l.code, + name: l.name, + native_name: l.native_name + } + }) + end + end +end diff --git a/app/tools/languages/list_team_languages_tool.rb b/app/tools/languages/list_team_languages_tool.rb new file mode 100644 index 0000000..b5174a6 --- /dev/null +++ b/app/tools/languages/list_team_languages_tool.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Languages + class ListTeamLanguagesTool < ApplicationTool + description "List active languages for the current team" + + annotations( + title: "List Team Languages", + read_only_hint: true, + open_world_hint: false + ) + + def call + require_user! + + languages = current_team.team_languages.active.includes(:language).map(&:language) + + success_response(languages.map { |l| + { + id: l.id, + code: l.code, + name: l.name, + native_name: l.native_name + } + }) + end + end +end diff --git a/app/tools/languages/remove_team_language_tool.rb b/app/tools/languages/remove_team_language_tool.rb new file mode 100644 index 0000000..c225ec3 --- /dev/null +++ b/app/tools/languages/remove_team_language_tool.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Languages + class RemoveTeamLanguageTool < ApplicationTool + description "Remove a language from the team (soft-disable, translations preserved)" + + annotations( + title: "Remove Team Language", + read_only_hint: false, + open_world_hint: false + ) + + arguments do + required(:language_code).filled(:string).description("ISO 639-1 language code (e.g., 'es', 'fr')") + end + + def call(language_code:) + require_user! + + membership = current_user.membership_for(current_team) + unless membership&.admin? + return error_response("Admin access required") + end + + language = Language.find_by(code: language_code) + return error_response("Language not found: #{language_code}") unless language + + if current_team.team_languages.active.count <= 1 + return error_response("At least one language is required") + end + + current_team.disable_language!(language) + + success_response( + { code: language.code, name: language.name }, + message: "#{language.name} removed from team languages" + ) + end + end +end diff --git a/app/tools/messages/create_message_tool.rb b/app/tools/messages/create_message_tool.rb new file mode 100644 index 0000000..7690e09 --- /dev/null +++ b/app/tools/messages/create_message_tool.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Messages + class CreateMessageTool < ApplicationTool + description "Send a message to a chat and get an AI response" + + annotations( + title: "Send Message", + read_only_hint: false, + open_world_hint: true + ) + + arguments do + required(:chat_id).filled(:string).description("The chat ID") + required(:content).filled(:string).description("The message content to send") + end + + def call(chat_id:, content:) + require_user! + + chat = current_user.chats.where(team: current_team).find_by(id: chat_id) + return error_response("Chat not found", code: "not_found") unless chat + + response = nil + with_current_user do + response = chat.ask(content) + end + + chat.reload + + success_response( + { + user_message: serialize_message(chat.messages.where(role: "user").last), + assistant_message: serialize_message(chat.messages.where(role: "assistant").last), + chat: { + id: chat.id, + messages_count: chat.messages_count, + total_cost: chat.total_cost.to_f + } + }, + message: "Message sent and response received" + ) + rescue FastMcp::Tool::InvalidArgumentsError + raise # Re-raise authentication errors + rescue => e + error_response("Failed to send message: #{e.message}", code: "api_error") + end + + private + + def serialize_message(message) + return nil unless message + + { + id: message.id, + role: message.role, + content: message.content, + model_id: message.model_id, + input_tokens: message.input_tokens, + output_tokens: message.output_tokens, + cost: message.cost&.to_f, + created_at: format_timestamp(message.created_at) + } + end + end +end diff --git a/app/tools/messages/list_messages_tool.rb b/app/tools/messages/list_messages_tool.rb new file mode 100644 index 0000000..a831ad9 --- /dev/null +++ b/app/tools/messages/list_messages_tool.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Messages + class ListMessagesTool < ApplicationTool + description "List messages in a chat with pagination support" + + annotations( + title: "List Messages", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + required(:chat_id).filled(:string).description("The chat ID") + optional(:limit).filled(:integer).description("Maximum number of messages to return (default: 50)") + optional(:after_id).filled(:string).description("Return messages after this message ID (for pagination)") + end + + def call(chat_id:, limit: 50, after_id: nil) + require_user! + + chat = current_user.chats.where(team: current_team).find_by(id: chat_id) + return error_response("Chat not found", code: "not_found") unless chat + + messages = chat.messages.order(:created_at) + + if after_id.present? + after_message = messages.find_by(id: after_id) + if after_message + messages = messages.where("created_at > ?", after_message.created_at) + end + end + + messages = messages.limit(limit) + + success_response( + messages.map { |msg| serialize_message(msg) }, + message: "Found #{messages.size} messages" + ) + end + + private + + def serialize_message(message) + { + id: message.id, + role: message.role, + content: message.content, + model_id: message.model_id, + input_tokens: message.input_tokens, + output_tokens: message.output_tokens, + cost: message.cost&.to_f, + created_at: format_timestamp(message.created_at) + } + end + end +end diff --git a/app/tools/models/list_models_tool.rb b/app/tools/models/list_models_tool.rb new file mode 100644 index 0000000..0a8ac07 --- /dev/null +++ b/app/tools/models/list_models_tool.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Models + class ListModelsTool < ApplicationTool + description "List available AI models" + + annotations( + title: "List Models", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + optional(:provider).filled(:string).description("Filter by provider (e.g., 'openai', 'anthropic')") + optional(:enabled_only).filled(:bool).description("Only return models with configured API keys (default: true)") + end + + def call(provider: nil, enabled_only: true) + models = enabled_only ? Model.enabled : Model.all + models = models.where(provider: provider) if provider.present? + models = models.order(:provider, :name) + + success_response( + models.map { |model| serialize_model(model) }, + message: "Found #{models.size} models" + ) + end + + private + + def serialize_model(model) + { + id: model.id, + model_id: model.model_id, + name: model.name, + provider: model.provider, + family: model.family, + context_window: model.context_window, + max_output_tokens: model.max_output_tokens, + capabilities: model.capabilities, + pricing: model.pricing, + chats_count: model.chats_count, + total_cost: model.total_cost.to_f + } + end + end +end diff --git a/app/tools/models/refresh_models_tool.rb b/app/tools/models/refresh_models_tool.rb new file mode 100644 index 0000000..7663a9d --- /dev/null +++ b/app/tools/models/refresh_models_tool.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Models + class RefreshModelsTool < ApplicationTool + description "Refresh the list of available models from all configured providers (admin only)" + + admin_only! + + annotations( + title: "Refresh Models", + read_only_hint: false, + open_world_hint: true + ) + + arguments do + # No arguments required + end + + def call + require_admin! + + # Use RubyLLM to sync models from providers + Model.sync_from_ruby_llm! + + models = Model.enabled.order(:provider, :name) + + success_response( + { + models_count: models.count, + providers: Model.configured_providers, + models: models.map { |m| { id: m.id, name: m.name, provider: m.provider } } + }, + message: "Models refreshed successfully" + ) + rescue FastMcp::Tool::InvalidArgumentsError + raise # Re-raise authentication errors + rescue => e + error_response("Failed to refresh models: #{e.message}", code: "refresh_error") + end + end +end diff --git a/app/tools/models/show_model_tool.rb b/app/tools/models/show_model_tool.rb new file mode 100644 index 0000000..a10ec54 --- /dev/null +++ b/app/tools/models/show_model_tool.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Models + class ShowModelTool < ApplicationTool + description "Get detailed information about a specific model" + + annotations( + title: "Show Model", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + required(:model_id).filled(:string).description("The model ID (UUIDv7) or model_id string (e.g., 'gpt-4')") + end + + def call(model_id:) + model = Model.find_by(id: model_id) || Model.find_by(model_id: model_id) + return error_response("Model not found", code: "not_found") unless model + + success_response(serialize_model(model)) + end + + private + + def serialize_model(model) + { + id: model.id, + model_id: model.model_id, + name: model.name, + provider: model.provider, + family: model.family, + context_window: model.context_window, + max_output_tokens: model.max_output_tokens, + knowledge_cutoff: model.knowledge_cutoff&.to_s, + capabilities: model.capabilities, + modalities: model.modalities, + pricing: model.pricing, + metadata: model.metadata, + chats_count: model.chats_count, + total_cost: model.total_cost.to_f, + created_at: format_timestamp(model.created_at), + updated_at: format_timestamp(model.updated_at) + } + end + end +end diff --git a/app/tools/teams/invite_member_tool.rb b/app/tools/teams/invite_member_tool.rb new file mode 100644 index 0000000..9225d36 --- /dev/null +++ b/app/tools/teams/invite_member_tool.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Teams + class InviteMemberTool < ApplicationTool + description "Invite a user to join the current team (admin only)" + + annotations( + title: "Invite Team Member", + read_only_hint: false, + open_world_hint: false + ) + + arguments do + required(:email).filled(:string).description("Email address of the user to invite") + end + + def call(email:) + require_user! + + membership = current_user.membership_for(current_team) + unless membership&.admin? + return error_response("Admin access required to invite members", code: "forbidden") + end + + user = User.find_or_initialize_by(email: email) + if user.new_record? + user.name = email.split("@").first.titleize + user.save! + end + + if user.member_of?(current_team) + return error_response("User is already a member of this team", code: "already_member") + end + + token = user.signed_id(purpose: :magic_link, expires_in: 7.days) + invite_url = Rails.application.routes.url_helpers.verify_magic_link_url( + token: token, + team: current_team.slug, + invited_by: current_user.id, + host: Rails.application.config.action_mailer.default_url_options[:host] + ) + + UserMailer.team_invitation(user, current_team, current_user, invite_url).deliver_later + + success_response( + { + email: user.email, + team_slug: current_team.slug, + invitation_sent: true + }, + message: "Invitation sent to #{email}" + ) + rescue ActiveRecord::RecordInvalid => e + error_response(e.message, code: "validation_error") + end + end +end diff --git a/app/tools/teams/list_teams_tool.rb b/app/tools/teams/list_teams_tool.rb new file mode 100644 index 0000000..1537864 --- /dev/null +++ b/app/tools/teams/list_teams_tool.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Teams + class ListTeamsTool < ApplicationTool + description "List teams the current user belongs to" + + annotations( + title: "List Teams", + read_only_hint: true, + open_world_hint: false + ) + + def call + require_authentication! + + teams = current_user.teams.includes(:memberships) + + success_response( + teams.map { |team| serialize_team(team) }, + message: "Found #{teams.size} teams" + ) + end + + private + + def serialize_team(team) + membership = current_user.membership_for(team) + { + id: team.id, + name: team.name, + slug: team.slug, + role: membership&.role, + member_count: team.memberships.count, + created_at: format_timestamp(team.created_at) + } + end + end +end diff --git a/app/tools/teams/show_team_tool.rb b/app/tools/teams/show_team_tool.rb new file mode 100644 index 0000000..72f53e6 --- /dev/null +++ b/app/tools/teams/show_team_tool.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Teams + class ShowTeamTool < ApplicationTool + description "Get details of a specific team" + + annotations( + title: "Show Team", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + required(:slug).filled(:string).description("The team slug") + end + + def call(slug:) + require_authentication! + + team = current_user.teams.find_by(slug: slug) + return error_response("Team not found", code: "not_found") unless team + + success_response(serialize_team(team)) + end + + private + + def serialize_team(team) + membership = current_user.membership_for(team) + { + id: team.id, + name: team.name, + slug: team.slug, + role: membership&.role, + member_count: team.memberships.count, + total_chat_cost: team.total_chat_cost.to_f, + members: team.memberships.includes(:user, :invited_by).map { |m| serialize_membership(m) }, + created_at: format_timestamp(team.created_at) + } + end + + def serialize_membership(membership) + { + user_id: membership.user_id, + user_name: membership.user.name, + user_email: membership.user.email, + role: membership.role, + invited_by: membership.invited_by&.name, + joined_at: format_timestamp(membership.created_at) + } + end + end +end diff --git a/app/tools/users/show_current_user_tool.rb b/app/tools/users/show_current_user_tool.rb new file mode 100644 index 0000000..e49f8c3 --- /dev/null +++ b/app/tools/users/show_current_user_tool.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Users + class ShowCurrentUserTool < ApplicationTool + description "Get information about the currently authenticated user" + + annotations( + title: "Show Current User", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + # No arguments required + end + + def call + require_authentication! + + success_response(serialize_user(current_user)) + end + + private + + def serialize_user(user) + { + id: user.id, + email: user.email, + name: user.name, + locale: user.locale, + chats_count: user.chats.count, + total_cost: user.total_cost.to_f, + created_at: format_timestamp(user.created_at), + updated_at: format_timestamp(user.updated_at) + } + end + end +end diff --git a/app/tools/users/update_current_user_tool.rb b/app/tools/users/update_current_user_tool.rb new file mode 100644 index 0000000..1627590 --- /dev/null +++ b/app/tools/users/update_current_user_tool.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Users + class UpdateCurrentUserTool < ApplicationTool + description "Update the current user's profile" + + annotations( + title: "Update Profile", + read_only_hint: false, + open_world_hint: false + ) + + arguments do + optional(:name).filled(:string).description("The user's display name") + optional(:locale).filled(:string).description('Language code (e.g. "en", "es") or "auto" to clear') + end + + def call(name: nil, locale: nil) + require_authentication! + + updates = {} + updates[:name] = name if name.present? + updates[:locale] = locale == "auto" ? nil : locale if locale.present? + + if updates.empty? + return error_response("No updates provided", code: "no_updates") + end + + current_user.update!(updates) + + success_response( + { + id: current_user.id, + email: current_user.email, + name: current_user.name, + locale: current_user.locale, + updated_at: format_timestamp(current_user.updated_at) + }, + message: "Profile updated successfully" + ) + rescue ActiveRecord::RecordInvalid => e + error_response(e.message, code: "validation_error") + end + end +end diff --git a/app/views/admin_mailer/magic_link.html.erb b/app/views/admin_mailer/magic_link.html.erb new file mode 100644 index 0000000..8969f44 --- /dev/null +++ b/app/views/admin_mailer/magic_link.html.erb @@ -0,0 +1,18 @@ +<h1><%= t(".heading") %></h1> + +<p><%= t(".greeting") %></p> + +<p><%= t(".instruction") %></p> + +<p> + <a href="<%= @magic_link_url %>" style="display: inline-block; padding: 12px 24px; background-color: #DC2626; color: white; text-decoration: none; border-radius: 6px;"> + <%= t(".button") %> + </a> +</p> + +<p><%= t(".link_instruction") %></p> +<p><%= @magic_link_url %></p> + +<p><small><%= t(".expiry") %></small></p> + +<p><small><%= t(".ignore") %></small></p> diff --git a/app/views/admin_mailer/magic_link.text.erb b/app/views/admin_mailer/magic_link.text.erb new file mode 100644 index 0000000..1717d9a --- /dev/null +++ b/app/views/admin_mailer/magic_link.text.erb @@ -0,0 +1,9 @@ +<%= t(".greeting") %> + +<%= t(".instruction") %> + +<%= @magic_link_url %> + +<%= t(".expiry") %> + +<%= t(".ignore") %> diff --git a/app/views/admins/sessions/new.html.erb b/app/views/admins/sessions/new.html.erb new file mode 100644 index 0000000..2937e85 --- /dev/null +++ b/app/views/admins/sessions/new.html.erb @@ -0,0 +1,43 @@ +<div class="min-h-screen flex items-center justify-center"> + <div class="max-w-md w-full p-4"> + <div class="text-center mb-8"> + <div class="w-16 h-16 bg-gradient-to-br from-purple-500 to-pink-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg shadow-purple-500/25"> + <%= inline_svg "icons/shield.svg", class: "w-8 h-8 text-white" %> + </div> + <h1 class="text-3xl font-bold text-dark-50 mb-2"><%= t(".heading") %></h1> + <p class="text-dark-400"><%= t(".subheading") %></p> + </div> + + <div class="bg-dark-950 py-8 px-6 rounded-xl border border-dark-700"> + <%= form_with scope: :session, url: admins_session_path, method: :post, class: "space-y-6" do |form| %> + <div> + <%= form.label :email, t(".email_label"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.email_field :email, + required: true, + autofocus: true, + autocomplete: "email", + placeholder: t(".email_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 focus:outline-none transition-all placeholder-dark-500" %> + <p class="mt-2 text-sm text-dark-400"> + <%= t(".email_help") %> + </p> + </div> + + <div> + <%= form.submit t(".submit"), + class: "w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white py-3 px-4 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-purple-500/25" %> + </div> + <% end %> + </div> + + <div class="mt-6 bg-amber-500/10 border border-amber-500/30 rounded-xl p-4"> + <div class="flex"> + <%= inline_svg "icons/shield.svg", class: "w-5 h-5 text-amber-400 mt-0.5 mr-3 flex-shrink-0" %> + <div class="text-sm text-amber-300"> + <strong class="font-medium"><%= t(".restricted_area") %></strong> + <p class="mt-1 text-amber-400/80"><%= t(".restricted_message") %></p> + </div> + </div> + </div> + </div> +</div> diff --git a/app/views/articles/_article.html.erb b/app/views/articles/_article.html.erb new file mode 100644 index 0000000..366d89c --- /dev/null +++ b/app/views/articles/_article.html.erb @@ -0,0 +1,15 @@ +<div class="card-bg p-6"> + <div class="flex items-start justify-between"> + <div> + <h2 class="text-xl font-semibold text-dark-100"> + <%= link_to article.title, team_article_path(current_team, article), class: "hover:text-accent-400 transition-colors" %> + </h2> + <p class="text-sm text-dark-400 mt-1"> + <%= article.user.name %> · <%= l(article.created_at, format: :short) %> + </p> + </div> + </div> + <% if article.body.present? %> + <p class="text-dark-300 mt-3 line-clamp-2"><%= article.body %></p> + <% end %> +</div> diff --git a/app/views/articles/_form.html.erb b/app/views/articles/_form.html.erb new file mode 100644 index 0000000..dcdecf5 --- /dev/null +++ b/app/views/articles/_form.html.erb @@ -0,0 +1,26 @@ +<%= form_with model: [current_team, article], url: form_url, class: "space-y-6" do |f| %> + <% if article.errors.any? %> + <div class="p-4 bg-red-900/20 border border-red-800 rounded-lg"> + <ul class="list-disc list-inside text-sm text-red-400"> + <% article.errors.full_messages.each do |message| %> + <li><%= message %></li> + <% end %> + </ul> + </div> + <% end %> + + <div> + <%= f.label :title, t(".title_label"), class: "block text-sm font-medium text-dark-300 mb-2" %> + <%= f.text_field :title, class: "w-full px-4 py-2 bg-dark-800 border border-dark-600 rounded-lg text-dark-100 focus:ring-2 focus:ring-accent-500 focus:border-accent-500", placeholder: t(".title_placeholder") %> + </div> + + <div> + <%= f.label :body, t(".body_label"), class: "block text-sm font-medium text-dark-300 mb-2" %> + <%= f.text_area :body, rows: 10, class: "w-full px-4 py-2 bg-dark-800 border border-dark-600 rounded-lg text-dark-100 focus:ring-2 focus:ring-accent-500 focus:border-accent-500", placeholder: t(".body_placeholder") %> + </div> + + <div class="flex items-center gap-4"> + <%= f.submit class: "px-6 py-2 bg-accent-600 hover:bg-accent-500 text-white rounded-lg transition-colors cursor-pointer" %> + <%= link_to t("common.back"), team_articles_path(current_team), class: "text-sm text-dark-400 hover:text-dark-200 transition-colors" %> + </div> +<% end %> diff --git a/app/views/articles/edit.html.erb b/app/views/articles/edit.html.erb new file mode 100644 index 0000000..7fc0360 --- /dev/null +++ b/app/views/articles/edit.html.erb @@ -0,0 +1,6 @@ +<% content_for :title, t(".heading") %> + +<div class="max-w-2xl mx-auto"> + <h1 class="text-3xl font-bold text-dark-50 mb-8"><%= t(".heading") %></h1> + <%= render "form", article: @article, form_url: team_article_path(current_team, @article) %> +</div> diff --git a/app/views/articles/index.html.erb b/app/views/articles/index.html.erb new file mode 100644 index 0000000..3518023 --- /dev/null +++ b/app/views/articles/index.html.erb @@ -0,0 +1,18 @@ +<% content_for :title, t(".heading") %> + +<div class="max-w-3xl mx-auto"> + <div class="flex items-center justify-between mb-8"> + <h1 class="text-3xl font-bold text-dark-50"><%= t(".heading") %></h1> + <%= link_to t(".new_article"), new_team_article_path(current_team), class: "inline-flex items-center gap-2 px-4 py-2 bg-accent-600 hover:bg-accent-500 text-white rounded-lg transition-colors" %> + </div> + + <% if @articles.any? %> + <div class="space-y-4"> + <%= render partial: "articles/article", collection: @articles, as: :article %> + </div> + <% else %> + <div class="card-bg p-8 text-center"> + <p class="text-dark-400"><%= t(".empty") %></p> + </div> + <% end %> +</div> diff --git a/app/views/articles/new.html.erb b/app/views/articles/new.html.erb new file mode 100644 index 0000000..f174a07 --- /dev/null +++ b/app/views/articles/new.html.erb @@ -0,0 +1,6 @@ +<% content_for :title, t(".heading") %> + +<div class="max-w-2xl mx-auto"> + <h1 class="text-3xl font-bold text-dark-50 mb-8"><%= t(".heading") %></h1> + <%= render "form", article: @article, form_url: team_articles_path(current_team) %> +</div> diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb new file mode 100644 index 0000000..937fdbc --- /dev/null +++ b/app/views/articles/show.html.erb @@ -0,0 +1,32 @@ +<% content_for :title, @article.title %> + +<div class="max-w-3xl mx-auto"> + <div class="mb-6"> + <%= link_to t("common.back"), team_articles_path(current_team), class: "text-sm text-dark-400 hover:text-dark-200 transition-colors" %> + </div> + + <article class="card-bg p-8"> + <div class="flex items-center justify-between mb-6"> + <div> + <h1 class="text-3xl font-bold text-dark-50"><%= @article.title %></h1> + <p class="text-sm text-dark-400 mt-2"> + <%= @article.user.name %> · <%= l(@article.created_at, format: :short) %> + </p> + </div> + <div class="flex items-center gap-2"> + <span class="px-2 py-1 text-xs font-mono text-dark-400 bg-dark-800 rounded"><%= detected_locale.upcase %></span> + </div> + </div> + + <% if @article.body.present? %> + <div class="prose prose-invert max-w-none text-dark-200"> + <%= simple_format(@article.body) %> + </div> + <% end %> + </article> + + <div class="flex items-center gap-4 mt-6"> + <%= link_to t(".edit"), edit_team_article_path(current_team, @article), class: "px-4 py-2 bg-dark-700 hover:bg-dark-600 text-dark-200 rounded-lg transition-colors text-sm" %> + <%= button_to t(".delete"), team_article_path(current_team, @article), method: :delete, class: "px-4 py-2 text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors text-sm", data: { turbo_confirm: t(".delete_confirm") } %> + </div> +</div> diff --git a/app/views/chats/_chat.html.erb b/app/views/chats/_chat.html.erb new file mode 100644 index 0000000..1f89619 --- /dev/null +++ b/app/views/chats/_chat.html.erb @@ -0,0 +1,16 @@ +<div id="<%= dom_id chat %>"> + <div> + <strong><%= t("models.show.model", default: "Model") %>:</strong> + <%= chat.model&.name || t("common.default") %> + </div> + + <div> + <strong><%= t("messages.count", default: "Messages") %>:</strong> + <%= chat.messages_count %> + </div> + + <div> + <strong><%= t("common.created", default: "Created") %>:</strong> + <%= chat.created_at.strftime("%B %d, %Y at %I:%M %p") %> + </div> +</div> diff --git a/app/views/chats/_form.html.erb b/app/views/chats/_form.html.erb new file mode 100644 index 0000000..ad30b25 --- /dev/null +++ b/app/views/chats/_form.html.erb @@ -0,0 +1,52 @@ +<%= form_with(model: chat, url: team_chats_path(current_team), data: { controller: "chat-input", chat_input_target: "form" }) do |form| %> + <% if chat.errors.any? %> + <div class="bg-red-500/10 text-red-300 px-4 py-3 rounded-lg mb-4"> + <ul class="list-disc list-inside text-sm"> + <% chat.errors.each do |error| %> + <li><%= error.full_message %></li> + <% end %> + </ul> + </div> + <% end %> + + <div class="bg-dark-700 rounded-2xl shadow-lg shadow-black/20 focus-within:shadow-[0_0_20px_rgba(255,255,255,0.08)] transition-shadow"> + <div class="p-3" data-chat-input-target="inputContainer"> + <%= form.text_area :prompt, + rows: 1, + class: "w-full resize-none border-0 bg-transparent focus:ring-0 focus:outline-none focus:border-0 focus:shadow-none placeholder-dark-400 text-dark-100", + placeholder: t("chats.form.prompt_placeholder"), + autofocus: true, + style: "box-shadow: none !important;", + data: { + chat_input_target: "textarea", + action: "keydown->chat-input#submit input->chat-input#resize" + } %> + </div> + <div class="hidden flex flex-wrap gap-2 px-3 pb-2" data-chat-input-target="previewArea"></div> + <div class="flex items-center justify-between px-3 py-2"> + <div class="flex items-center gap-2 text-dark-400"> + <button type="button" class="p-1.5 hover:bg-white/5 rounded-lg transition-colors" data-chat-input-target="attachButton" data-action="click->chat-input#triggerFileInput"> + <%= inline_svg "icons/plus.svg", class: "w-5 h-5" %> + </button> + <%= form.file_field :attachments, multiple: true, class: "hidden", data: { chat_input_target: "fileInput", action: "change->chat-input#handleFileSelect" }, accept: "image/png,image/jpeg,image/gif,image/webp,.pdf" %> + </div> + <div class="flex items-center gap-3"> + <div class="relative flex items-center"> + <% default_model = Model.find_by(model_id: RubyLLM.config.default_model) %> + <% default_label = default_model ? t("chats.form.default_model", name: default_model.name) : t("chats.form.default_model_not_set") %> + <%= form.select :model, + options_for_select([[default_label, ""]] + Model.enabled.order(:name).map { |m| [m.name, m.model_id] }, @selected_model), + {}, + class: "text-sm text-dark-400 select-transparent ring-0 outline-none focus:ring-0 focus:outline-none cursor-pointer pr-5 appearance-none", + data: { controller: "auto-width-select", action: "change->auto-width-select#resize" } %> + <div class="absolute right-0 top-1/2 -translate-y-1/2 pointer-events-none text-dark-400"> + <%= inline_svg "icons/chevron-down.svg", class: "w-4 h-4" %> + </div> + </div> + <%= form.button type: "submit", class: "bg-dark-600 hover:bg-dark-500 text-white rounded-lg p-2 transition-colors cursor-pointer" do %> + <%= inline_svg "icons/arrow-up.svg", class: "w-4 h-4" %> + <% end %> + </div> + </div> + </div> +<% end %> diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb new file mode 100644 index 0000000..a1f5453 --- /dev/null +++ b/app/views/chats/index.html.erb @@ -0,0 +1,61 @@ +<% content_for :title, t(".title") %> + +<div class="max-w-4xl mx-auto"> + <div class="flex items-center justify-between mb-8"> + <div> + <h1 class="text-2xl font-bold text-dark-50"><%= t(".heading") %></h1> + <p class="text-dark-400 mt-1"><%= t(".subheading") %></p> + </div> + <%= link_to new_team_chat_path(current_team), class: "inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white px-4 py-2 rounded-lg hover:from-blue-700 hover:to-blue-800 transition-all font-medium shadow-lg shadow-blue-500/25" do %> + <%= inline_svg "icons/plus.svg", class: "w-5 h-5" %> + <%= t(".new_chat") %> + <% end %> + </div> + + <div id="chats" class="space-y-3"> + <% if @chats.any? %> + <% @chats.each do |chat| %> + <%= link_to team_chat_path(current_team, chat), class: "block card-bg p-4 hover:bg-dark-800 transition-all group" do %> + <div class="flex items-start justify-between"> + <div class="flex-1 min-w-0"> + <div class="flex items-center gap-3 mb-1"> + <div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center flex-shrink-0"> + <%= inline_svg "icons/chat.svg", class: "w-4 h-4 text-white" %> + </div> + <div> + <h3 class="font-medium text-dark-100 group-hover:text-blue-400 transition-colors"> + <%= chat.model&.name || t("common.default") %> + </h3> + </div> + </div> + <% if chat.messages_count > 0 %> + <p class="text-dark-400 text-sm truncate pl-11"> + <% first_user_message = chat.messages.find { |m| m.role == "user" } %> + <%= first_user_message&.content&.truncate(100) || t(".no_messages") %> + </p> + <% end %> + </div> + <div class="flex items-center gap-3 ml-4 flex-shrink-0"> + <span class="text-xs text-dark-500"> + <%= t(".time_ago", time: time_ago_in_words(chat.created_at)) %> + </span> + <%= inline_svg "icons/chevron-right.svg", class: "w-4 h-4 text-dark-500 group-hover:text-blue-400 transition-colors" %> + </div> + </div> + <% end %> + <% end %> + <% else %> + <div class="text-center py-16 card-bg"> + <div class="w-16 h-16 bg-dark-800 rounded-full flex items-center justify-center mx-auto mb-4"> + <%= inline_svg "icons/chat.svg", class: "w-8 h-8 text-dark-500" %> + </div> + <h3 class="text-lg font-medium text-dark-100 mb-2"><%= t(".empty.heading") %></h3> + <p class="text-dark-400 mb-6"><%= t(".empty.description") %></p> + <%= link_to new_team_chat_path(current_team), class: "inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white px-4 py-2 rounded-lg hover:from-blue-700 hover:to-blue-800 transition-all font-medium" do %> + <%= inline_svg "icons/plus.svg", class: "w-5 h-5" %> + <%= t(".empty.cta") %> + <% end %> + </div> + <% end %> + </div> +</div> diff --git a/app/views/chats/new.html.erb b/app/views/chats/new.html.erb new file mode 100644 index 0000000..cb49f08 --- /dev/null +++ b/app/views/chats/new.html.erb @@ -0,0 +1,20 @@ +<% content_for :title, t(".title") %> + +<div class="max-w-2xl mx-auto py-12"> + <div class="text-center mb-8"> + <div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg shadow-purple-500/25"> + <%= inline_svg "icons/chat.svg", class: "w-8 h-8 text-white" %> + </div> + <h1 class="text-2xl font-bold text-dark-50 mb-2"><%= t(".heading") %></h1> + <p class="text-dark-400"><%= t(".subheading") %></p> + </div> + + <%= render "form", chat: @chat %> + + <div class="text-center mt-6"> + <%= link_to team_chats_path(current_team), class: "inline-flex items-center gap-2 text-dark-400 hover:text-dark-200 transition-colors" do %> + <%= inline_svg "icons/chevron-left.svg", class: "w-4 h-4" %> + <%= t(".back") %> + <% end %> + </div> +</div> diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb new file mode 100644 index 0000000..2de1253 --- /dev/null +++ b/app/views/chats/show.html.erb @@ -0,0 +1,29 @@ +<%= turbo_stream_from "chat_#{@chat.id}" %> + +<% content_for :title, t(".title", default: "Chat") %> +<% content_for :full_width, true %> + +<div class="relative h-[calc(100vh-64px)] lg:h-screen"> + <%# Messages container - full height with scroll %> + <div class="absolute inset-0 overflow-y-auto" data-controller="scroll-bottom"> + <%# Top gradient fade %> + <div class="sticky top-0 h-12 bg-gradient-to-b from-dark-800 to-transparent pointer-events-none z-10"></div> + + <div id="messages" class="max-w-4xl mx-auto px-4 pb-72"> + <%= render partial: "messages/message", collection: @chat.messages.reject { |m| m.id.nil? }, as: :message %> + </div> + </div> + + <%# Input area - floating at bottom %> + <div class="absolute bottom-0 left-0 right-0 z-20 pointer-events-none"> + <%# Bottom gradient fade %> + <div class="h-24 bg-gradient-to-t from-dark-800 to-transparent"></div> + + <%# Input container %> + <div class="bg-dark-800 pb-6 pointer-events-auto"> + <div class="max-w-4xl mx-auto px-4"> + <%= render "messages/form", chat: @chat, message: @message %> + </div> + </div> + </div> +</div> diff --git a/app/views/layouts/admin_auth.html.erb b/app/views/layouts/admin_auth.html.erb new file mode 100644 index 0000000..0893f92 --- /dev/null +++ b/app/views/layouts/admin_auth.html.erb @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html class="dark"> + <head> + <title>Admin Login + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + + + + + + + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <%= render "shared/flash" %> + <%= yield %> + + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 1ea3bab..d86420a 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -164,7 +164,7 @@
<% if current_user.admin? %>
- <%= link_to "Admin Panel", "/avo", class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:bg-gray-100 focus:text-gray-900 focus:outline-none cursor-pointer" %> + <%= link_to "Admin Panel", "/madmin", class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:bg-gray-100 focus:text-gray-900 focus:outline-none cursor-pointer" %>
<% end %>
@@ -239,7 +239,7 @@
<%= link_to "Profile", user_path(current_user), class: "block px-3 py-2 rounded-md text-base font-medium text-gray-600 hover:bg-gray-100" %> <% if current_user.admin? %> - <%= link_to "Admin Panel", "/avo", class: "block px-3 py-2 rounded-md text-base font-medium text-gray-600 hover:bg-gray-100" %> + <%= link_to "Admin Panel", "/madmin", class: "block px-3 py-2 rounded-md text-base font-medium text-gray-600 hover:bg-gray-100" %> <% end %> <%= button_to "Sign out", destroy_user_session_path, method: :delete, data: { turbo: false }, class: "block w-full text-left px-3 py-2 rounded-md text-base font-medium text-gray-600 hover:bg-gray-100" %>
diff --git a/app/views/layouts/madmin/application.html.erb b/app/views/layouts/madmin/application.html.erb new file mode 100644 index 0000000..d0f057c --- /dev/null +++ b/app/views/layouts/madmin/application.html.erb @@ -0,0 +1,53 @@ + + + + + + + + + <% if content_for? :title %> + <%= yield(:title) %> - + <% end %> + <%= Madmin.site_name %> Admin + + <%= csrf_meta_tags %> + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + <%= render "javascript" %> + <%= javascript_importmap_tags %> + + + + <%# Sidebar Navigation %> + <%= render "madmin/application/sidebar" %> + + <%# Mobile header %> +
+ + <%= Madmin.site_name %> Admin +
+ + <%# Main content area %> +
+ <%# Spacer for mobile header %> +
+ + <%# Flash messages (using shared partial for consistency) %> + <%= render "shared/flash" %> + + <%# Page content %> +
+ <%= yield %> +
+
+ + <%# Mobile overlay %> + + + diff --git a/app/views/layouts/onboarding.html.erb b/app/views/layouts/onboarding.html.erb new file mode 100644 index 0000000..88cd6e1 --- /dev/null +++ b/app/views/layouts/onboarding.html.erb @@ -0,0 +1,30 @@ + + + + <%= content_for(:title) || t("app_name") %> + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + + + + + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + <% if nullitics_enabled? %> + <% cc = country_code %> + + <% end %> + + + + <% if nullitics_enabled? %><% end %> + <%= render "shared/flash" %> + +
+ <%= yield %> +
+ + diff --git a/app/views/madmin/active_storage/attachments/index.html.erb b/app/views/madmin/active_storage/attachments/index.html.erb new file mode 100644 index 0000000..3095e57 --- /dev/null +++ b/app/views/madmin/active_storage/attachments/index.html.erb @@ -0,0 +1,80 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search attachments...", class: "form-input" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + <% end %> + +
<%= sortable :blob_id, "Blob Filename" %><%= sortable :name, "Name" %><%= sortable :record_type, "Record" %><%= sortable :created_at, "Created" %>
+ <%= link_to resource.show_path(record), class: "flex items-center gap-3 font-medium hover:underline" do %> + <% if record.blob&.image? && record.blob&.variable? %> +
+ <%= image_tag main_app.url_for(record.blob.variant(resize_to_limit: [80, 80])), class: "w-full h-full object-cover" %> +
+ <% elsif record.blob&.image? %> +
+ <%= image_tag main_app.url_for(record.blob), class: "w-full h-full object-cover" %> +
+ <% else %> +
+ <%= record.blob&.filename&.extension&.to_s&.upcase&.truncate(4, omission: "") || "?" %> +
+ <% end %> + <%= record.blob&.filename || "Unknown" %> + <% end %> +
<%= record.name %> + <% if record.record_type == "Message" && record.record %> + <%= link_to "/madmin/messages/#{record.record_id}", class: "block truncate hover:underline" do %> + <%= record.record.content&.truncate(50) || "Message ##{record.record_id.to_s[0..7]}" %> + <% end %> + <% else %> + <%= record.record_type %> #<%= record.record_id.to_s[0..7] %> + <% end %> + <%= record.created_at.strftime("%-d %B %Y") %>
+
+ + diff --git a/app/views/madmin/active_storage/attachments/show.html.erb b/app/views/madmin/active_storage/attachments/show.html.erb new file mode 100644 index 0000000..f4d635c --- /dev/null +++ b/app/views/madmin/active_storage/attachments/show.html.erb @@ -0,0 +1,111 @@ +<%= content_for :title, @record.blob&.filename || "Attachment" %> + +
+

+ <%= link_to resource.friendly_name.pluralize, resource.index_path %> + / + <%= @record.blob&.filename || "Attachment" %> +

+ +
+ <%= button_to "Delete", resource.show_path(@record), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "btn btn-danger" %> +
+
+ +
+
+ <% if @record.blob&.image? %> +
+ <%= image_tag main_app.rails_blob_path(@record.blob, disposition: :inline), class: "w-full h-full object-cover" %> +
+ <% else %> +
+ <%= @record.blob&.filename&.extension&.upcase || "?" %> +
+ <% end %> +
+

<%= @record.blob&.filename || "Unknown" %>

+

Attached to <%= @record.record_type %>

+

Created <%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>

+
+
+
+ +
+
+
Attachment Name
+
<%= @record.name %>
+
+
+
Record Type
+
<%= @record.record_type %>
+
+
+
File Size
+
<%= @record.blob ? number_to_human_size(@record.blob.byte_size) : "-" %>
+
+
+ +
+
+

Details

+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Name<%= @record.name %>
Record Type<%= @record.record_type %>
Record ID<%= @record.record_id %>
Blob + <% if @record.blob %> + <%= link_to @record.blob.filename, "/madmin/active_storage/blobs/#{@record.blob_id}", class: "text-accent-400 hover:underline" %> + (<%= number_to_human_size(@record.blob.byte_size) %>) + <% else %> + - + <% end %> +
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
+
+
+ +<% if @record.blob %> +
+
+

Preview

+ + + Download + +
+
+ <% if @record.blob.image? %> + + <% else %> + + <% end %> +
+
+<% end %> diff --git a/app/views/madmin/active_storage/blobs/index.html.erb b/app/views/madmin/active_storage/blobs/index.html.erb new file mode 100644 index 0000000..f9d4d19 --- /dev/null +++ b/app/views/madmin/active_storage/blobs/index.html.erb @@ -0,0 +1,72 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search by filename...", class: "form-input" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + <% end %> + +
<%= sortable :filename, "Filename" %><%= sortable :content_type, "Content Type" %><%= sortable :byte_size, "Size" %><%= sortable :created_at, "Created" %>
+ <%= link_to resource.show_path(record), class: "flex items-center gap-3 font-medium hover:underline" do %> + <% if record.image? && record.variable? %> +
+ <%= image_tag main_app.url_for(record.variant(resize_to_limit: [80, 80])), class: "w-full h-full object-cover" %> +
+ <% elsif record.image? %> +
+ <%= image_tag main_app.url_for(record), class: "w-full h-full object-cover" %> +
+ <% else %> +
+ <%= record.filename.extension.upcase.truncate(4, omission: "") %> +
+ <% end %> + <%= record.filename %> + <% end %> +
<%= record.content_type %><%= number_to_human_size(record.byte_size) %><%= record.created_at.strftime("%-d %B %Y") %>
+
+ + diff --git a/app/views/madmin/active_storage/blobs/show.html.erb b/app/views/madmin/active_storage/blobs/show.html.erb new file mode 100644 index 0000000..4bf6ba4 --- /dev/null +++ b/app/views/madmin/active_storage/blobs/show.html.erb @@ -0,0 +1,117 @@ +<%= content_for :title, @record.filename %> + +
+

+ <%= link_to resource.friendly_name.pluralize, resource.index_path %> + / + <%= @record.filename %> +

+ +
+ <%= button_to "Delete", resource.show_path(@record), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "btn btn-danger" %> +
+
+ +
+
+ <% if @record.image? %> +
+ <%= image_tag main_app.rails_blob_path(@record, disposition: :inline), class: "w-full h-full object-cover" %> +
+ <% else %> +
+ <%= inline_svg "icons/document.svg", class: "w-8 h-8 text-dark-400" rescue content_tag(:span, @record.filename.extension.upcase, class: "text-sm font-bold text-dark-400") %> +
+ <% end %> +
+

<%= @record.filename %>

+

<%= @record.content_type %>

+

Uploaded <%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>

+
+
+
+ +
+
+
File Size
+
<%= number_to_human_size(@record.byte_size) %>
+
<%= number_with_delimiter(@record.byte_size) %> bytes
+
+
+
Content Type
+
<%= @record.content_type %>
+
+
+
Service
+
<%= @record.service_name %>
+
+
+ +
+
+

Details

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <% if @record.metadata.present? %> + + + + + <% end %> + +
Filename<%= @record.filename %>
Key<%= @record.key %>
Content Type<%= @record.content_type %>
Size<%= number_to_human_size(@record.byte_size) %> (<%= number_with_delimiter(@record.byte_size) %> bytes)
Checksum<%= @record.checksum || "-" %>
Service<%= @record.service_name %>
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
Metadata
<%= @record.metadata %>
+
+
+ +
+
+

Preview

+ + + Download + +
+
+ <% if @record.image? %> + + <% else %> + + <% end %> +
+
diff --git a/app/views/madmin/active_storage/variant_records/index.html.erb b/app/views/madmin/active_storage/variant_records/index.html.erb new file mode 100644 index 0000000..6680238 --- /dev/null +++ b/app/views/madmin/active_storage/variant_records/index.html.erb @@ -0,0 +1,39 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+
+ +
+ + + + + + + + + + <% @records.each do |record| %> + + + + + <% end %> + +
<%= sortable :blob_id, "Blob Filename" %><%= sortable :variation_digest, "Variation Digest" %>
+ <%= link_to resource.show_path(record), class: "font-medium hover:underline" do %> + <%= record.blob&.filename || "Unknown" %> + <% end %> + <%= record.variation_digest.to_s.truncate(30) %>
+
+ + diff --git a/app/views/madmin/active_storage/variant_records/show.html.erb b/app/views/madmin/active_storage/variant_records/show.html.erb new file mode 100644 index 0000000..6c12e33 --- /dev/null +++ b/app/views/madmin/active_storage/variant_records/show.html.erb @@ -0,0 +1,87 @@ +<%= content_for :title, @record.blob&.filename || "Variant Record" %> + +
+

+ <%= link_to resource.friendly_name.pluralize, resource.index_path %> + / + <%= @record.blob&.filename || "Variant Record" %> +

+ +
+ <%= button_to "Delete", resource.show_path(@record), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "btn btn-danger" %> +
+
+ +
+
+ <% if @record.blob&.image? %> +
+ <%= image_tag main_app.rails_blob_path(@record.blob, disposition: :inline), class: "w-full h-full object-cover" %> +
+ <% else %> +
+ <%= @record.blob&.filename&.extension&.upcase || "?" %> +
+ <% end %> +
+

<%= @record.blob&.filename || "Unknown" %>

+

Variant of blob

+

Digest: <%= @record.variation_digest&.truncate(40) %>

+
+
+
+ +
+
+
Blob Filename
+
<%= @record.blob&.filename || "-" %>
+
+
+
Blob Size
+
<%= @record.blob ? number_to_human_size(@record.blob.byte_size) : "-" %>
+
+
+ +
+
+

Details

+
+
+ + + + + + + + + + + + + + + +
ID<%= @record.id %>
Blob + <% if @record.blob %> + <%= link_to @record.blob.filename, "/madmin/active_storage/blobs/#{@record.blob_id}", class: "text-accent-400 hover:underline" %> + (<%= number_to_human_size(@record.blob.byte_size) %>) + <% else %> + - + <% end %> +
Variation Digest<%= @record.variation_digest %>
+
+
+ +<% if @record.blob&.image? %> +
+
+

Original Blob Preview

+
+
+
+ <%= image_tag main_app.rails_blob_path(@record.blob, disposition: :inline), class: "max-w-full h-auto rounded" %> +
+
+
+<% end %> diff --git a/app/views/madmin/admins/index.html.erb b/app/views/madmin/admins/index.html.erb new file mode 100644 index 0000000..41e4f72 --- /dev/null +++ b/app/views/madmin/admins/index.html.erb @@ -0,0 +1,62 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+ +
+ <%= link_to resource.new_path, class: "btn btn-secondary" do %> + New <%= tag.span resource.friendly_name, class: "hidden md:inline-block" %> + <% end %> +
+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search admins...", class: "form-input" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + <% @records.each do |record| %> + + + + + <% end %> + +
<%= sortable :email, "Email" %><%= sortable :created_at, "Created" %>
+ <%= link_to resource.show_path(record), class: "flex items-center gap-3" do %> + + <%= record.email %> + <% end %> + <%= record.created_at.strftime("%-d %B %Y") %>
+
+ + diff --git a/app/views/madmin/admins/show.html.erb b/app/views/madmin/admins/show.html.erb new file mode 100644 index 0000000..857aeeb --- /dev/null +++ b/app/views/madmin/admins/show.html.erb @@ -0,0 +1,38 @@ +<%= content_for :title, resource.display_name(@record) %> + +
+

+ <%= link_to resource.friendly_name.pluralize, resource.index_path %> + / + <%= resource.display_name(@record) %> +

+ +
+ <% resource.member_actions.each do |action| %> + <%= instance_exec(@record, &action) %> + <% end %> + <%= link_to "Edit", resource.edit_path(@record), class: "btn btn-secondary" %> + <%= button_to "Delete", resource.show_path(@record), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "btn btn-danger" %> +
+
+ +
+ + + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + + + + + + <% end %> + +
+ <%= attribute.field.options.label || attribute.name.to_s.titleize %> + + <%= render partial: attribute.field.to_partial_path("show"), locals: { field: attribute.field, record: @record, resource: resource } %> +
+
diff --git a/app/views/madmin/application/_form.html.erb b/app/views/madmin/application/_form.html.erb new file mode 100644 index 0000000..a391691 --- /dev/null +++ b/app/views/madmin/application/_form.html.erb @@ -0,0 +1,44 @@ +<%= form_with model: [:madmin, record], url: (record.persisted? ? resource.show_path(record) : resource.index_path), local: true, class: "space-y-6" do |form| %> + <% if form.object.errors.any? %> +
+
+ <%= inline_svg "icons/x-circle.svg", class: "w-5 h-5 text-red-400 mt-0.5 shrink-0" %> +
+

+ <%= pluralize form.object.errors.full_messages.count, "error" %> prevented this <%= resource.friendly_name.downcase %> from being saved: +

+
    + <% form.object.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+ <% resource.attributes.values.select{ _1.field.present? && _1.field.visible?(action_name) }.each do |attribute| %> +
+ + <%= render partial: attribute.field.to_partial_path("form"), locals: { field: attribute.field, record: record, form: form, resource: resource } %> + <% if attribute.field.options.description.present? %> +

<%= attribute.field.options.description %>

+ <% end %> +
+ <% end %> +
+ +
+ <%= form.submit class: "btn btn-primary cursor-pointer" %> + <%= link_to "Cancel", (record.persisted? ? resource.show_path(record) : resource.index_path), class: "btn btn-secondary" %> + <% if record.persisted? %> + <%= link_to "View", resource.show_path(record), class: "btn btn-secondary ml-auto" %> + <% end %> +
+<% end %> diff --git a/app/views/madmin/application/_javascript.html.erb b/app/views/madmin/application/_javascript.html.erb new file mode 100644 index 0000000..597556f --- /dev/null +++ b/app/views/madmin/application/_javascript.html.erb @@ -0,0 +1,6 @@ +<%= tag.script 'import "trix"'.html_safe, type: "module" if defined?(::Trix) || Rails.gem_version < Gem::Version.new("8.1.0.beta1") %> +<%= tag.script 'import "lexxy"'.html_safe, type: "module" if defined?(::Lexxy) %> + +<%# Note: Madmin.stylesheets removed - using custom dark theme layout instead %> +<%= stylesheet_link_tag "https://unpkg.com/flatpickr/dist/flatpickr.min.css", "data-turbo-track": "reload" %> +<%= stylesheet_link_tag "https://unpkg.com/tom-select/dist/css/tom-select.min.css", "data-turbo-track": "reload" %> diff --git a/app/views/madmin/application/_navigation.html.erb b/app/views/madmin/application/_navigation.html.erb new file mode 100644 index 0000000..140d06d --- /dev/null +++ b/app/views/madmin/application/_navigation.html.erb @@ -0,0 +1,30 @@ + + +
+
+ <%= button_to "Sign Out", + main_app.admins_session_path, + method: :delete, + class: "btn btn-sm", + data: { turbo_confirm: "Sign out from admin panel?" } %> +
+ + <%= link_to "https://github.com/excid3/madmin", target: :_blank do %> + <%= inline_svg "icons/github.svg", class: "w-4 h-4" %> + Madmin on GitHub + <% end %> +
diff --git a/app/views/madmin/application/_sidebar.html.erb b/app/views/madmin/application/_sidebar.html.erb new file mode 100644 index 0000000..c50912c --- /dev/null +++ b/app/views/madmin/application/_sidebar.html.erb @@ -0,0 +1,116 @@ +<%# Modern dark sidebar navigation for Madmin - uses shared partials for consistent design %> + +<%# Sidebar %> + diff --git a/app/views/madmin/application/edit.html.erb b/app/views/madmin/application/edit.html.erb new file mode 100644 index 0000000..d58c58d --- /dev/null +++ b/app/views/madmin/application/edit.html.erb @@ -0,0 +1,17 @@ +<%= content_for :title, "Edit #{resource.display_name(@record)}" %> + +
+ +

Edit <%= resource.friendly_name %>

+

Update the <%= resource.friendly_name.downcase %> details below

+
+ +
+ <%= render partial: "form", locals: { record: @record, resource: resource } %> +
diff --git a/app/views/madmin/application/index.html.erb b/app/views/madmin/application/index.html.erb new file mode 100644 index 0000000..9d05b6d --- /dev/null +++ b/app/views/madmin/application/index.html.erb @@ -0,0 +1,78 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+ +
+ + + <% if params[:q].present? %> + <%= link_to resource.index_path, class: "btn btn-secondary" do %> + Clear + <% end %> + <% end %> + + <% if resource.actions.include?(:new) %> + <%= link_to resource.new_path, class: "btn btn-primary" do %> + New <%= resource.friendly_name %> + <% end %> + <% end %> +
+
+ +<% if resource.scopes.any? %> + +<% end %> + +<% + visible_attributes = resource.attributes.values.select { |a| a.field.present? && a.field.visible?(action_name) && a.name.to_s != "id" } + first_linkable = visible_attributes.first +%> + +
+ + + + <% visible_attributes.each do |attribute| %> + + <% end %> + + + + + <% @records.each do |record| %> + + <% visible_attributes.each_with_index do |attribute, index| %> + + <% end %> + + <% end %> + +
<%= sortable attribute.name, attribute.name.to_s.titleize %>
+ <% if index == 0 %> + <%= link_to resource.show_path(record), class: "font-medium hover:underline" do %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> + <% else %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> +
+
+ + diff --git a/app/views/madmin/application/new.html.erb b/app/views/madmin/application/new.html.erb new file mode 100644 index 0000000..ffc73bc --- /dev/null +++ b/app/views/madmin/application/new.html.erb @@ -0,0 +1,15 @@ +<%= content_for :title, "New #{resource.friendly_name}" %> + +
+ +

New <%= resource.friendly_name %>

+

Create a new <%= resource.friendly_name.downcase %>

+
+ +
+ <%= render partial: "form", locals: { record: @record, resource: resource } %> +
diff --git a/app/views/madmin/application/show.html.erb b/app/views/madmin/application/show.html.erb new file mode 100644 index 0000000..2cfef8d --- /dev/null +++ b/app/views/madmin/application/show.html.erb @@ -0,0 +1,52 @@ +<%= content_for :title, resource.display_name(@record) %> + +
+ + +
+
+

<%= resource.display_name(@record) %>

+

<%= resource.friendly_name %> details

+
+ +
+ <% resource.member_actions.each do |action| %> + <%= instance_exec(@record, &action) %> + <% end %> + <% if resource.actions.include?(:edit) %> + <%= link_to resource.edit_path(@record), class: "btn btn-secondary" do %> + <%= inline_svg "icons/pencil.svg", class: "w-4 h-4" %> + Edit + <% end %> + <% end %> + <% if resource.actions.include?(:destroy) || resource.actions.include?(:delete) %> + <%= button_to resource.show_path(@record), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this #{resource.friendly_name.downcase}?" }, class: "btn btn-danger" do %> + <%= inline_svg "icons/trash.svg", class: "w-4 h-4" %> + Delete + <% end %> + <% end %> +
+
+
+ +
+
+ <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + +
+
+ <%= attribute.field.options.label || attribute.name.to_s.titleize %> +
+
+ <%= render partial: attribute.field.to_partial_path("show"), locals: { field: attribute.field, record: @record, resource: resource } %> +
+
+ <% end %> +
+
diff --git a/app/views/madmin/categories/index.html.erb b/app/views/madmin/categories/index.html.erb new file mode 100644 index 0000000..6faa69c --- /dev/null +++ b/app/views/madmin/categories/index.html.erb @@ -0,0 +1,64 @@ +<%= content_for :title, "Categories" %> + +
+

Categories

+
+ +
+ + + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + + <% end %> + +
<%= sortable :position, "#" %><%= sortable :name, "Category" %>PostsType<%= sortable :created_at, "Created" %>
+ + <%= record.position %> + + + <%= link_to resource.show_path(record), class: "block" do %> +
<%= record.name %>
+ <% if record.description.present? %> +
<%= record.description.truncate(80) %>
+ <% end %> + <% end %> +
+ <%= record.posts.size %> + + <% if record.is_success_story? %> + + Success Stories + + <% else %> + + Regular + + <% end %> + <%= record.created_at.strftime("%-d %b %Y") %>
+
+ + diff --git a/app/views/madmin/categories/show.html.erb b/app/views/madmin/categories/show.html.erb new file mode 100644 index 0000000..c31f180 --- /dev/null +++ b/app/views/madmin/categories/show.html.erb @@ -0,0 +1,107 @@ +<%= content_for :title, @record.name %> + +
+

+ <%= link_to "Categories", resource.index_path %> + / + <%= @record.name %> +

+
+ <%= link_to "Edit", resource.edit_path(@record), class: "btn btn-secondary" %> +
+
+ +
+
+
+ <%= @record.position %> +
+
+

<%= @record.name %>

+ <% if @record.description.present? %> +

<%= @record.description %>

+ <% end %> +
+ <% if @record.is_success_story? %> + Success Story Category + <% end %> + Slug: <%= @record.slug %> +
+
+
+
+ +
+
+
Total Posts
+
<%= @record.posts.size %>
+
+
+
Published
+
<%= @record.posts.published.size %>
+
+
+
Drafts
+
<%= @record.posts.size - @record.posts.published.size %>
+
+
+ +<%# Details %> +
+
+

Details

+
+
+ + + + + + + + + + +
ID<%= @record.id %>
Name<%= @record.name %>
Slug<%= @record.slug %>
Position<%= @record.position %>
Success Story<%= @record.is_success_story? ? "Yes" : "No" %>
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
Updated<%= @record.updated_at.strftime("%-d %B %Y at %H:%M") %>
+
+
+ +<%# Recent posts %> +<% recent_posts = @record.posts.includes(:user).order(created_at: :desc).limit(10) %> +<% if recent_posts.any? %> +
+
+

Recent Posts

+
+
+ + + + + + + + + + + <% recent_posts.each do |post| %> + + + + + + + <% end %> + +
TitleAuthorStatusDate
+ <%= link_to post.title.truncate(60), "/madmin/posts/#{post.id}", class: "truncate block text-accent-400 hover:text-accent-300" %> + <%= post.user.display_name %> + <% if post.published? %> + Published + <% else %> + Draft + <% end %> + <%= post.created_at.strftime("%-d %b %Y") %>
+
+
+<% end %> diff --git a/app/views/madmin/chats/index.html.erb b/app/views/madmin/chats/index.html.erb new file mode 100644 index 0000000..ebf88a4 --- /dev/null +++ b/app/views/madmin/chats/index.html.erb @@ -0,0 +1,80 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+ <% setting = Setting.instance %> + <%= button_to main_app.toggle_public_chats_madmin_chats_path, method: :patch, class: "inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors #{setting.public_chats? ? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25' : 'bg-dark-700 text-dark-400 hover:bg-dark-600'}" do %> + + Public Chats <%= setting.public_chats? ? "On" : "Off" %> + <% end %> +
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search by user email...", class: "form-input" %> +
+ +
+ <%= f.label :created_at_from, "From:", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.date_field :created_at_from, value: params[:created_at_from], class: "form-input" %> +
+ +
+ <%= f.label :created_at_to, "To:", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.date_field :created_at_to, value: params[:created_at_to], class: "form-input" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? || params[:created_at_from].present? || params[:created_at_to].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + + <% end %> + +
<%= sortable :id, "First Message" %><%= sortable :user_id, "User" %><%= sortable :messages_count, "Messages" %><%= sortable :total_cost, "Cost" %><%= sortable :created_at, "Created" %>
+ <%= link_to resource.show_path(record), class: "block truncate" do %> + <%= record.messages.min_by(&:created_at)&.content&.truncate(80) || "Empty chat" %> + <% end %> + + <%= link_to "/madmin/users/#{record.user_id}", class: "hover:underline" do %> + <%= record.user&.email %> + <% end %> + <%= record.messages_count %><%= record.formatted_total_cost || "-" %><%= record.created_at.strftime("%-d %B %Y") %>
+
+ + diff --git a/app/views/madmin/chats/show.html.erb b/app/views/madmin/chats/show.html.erb new file mode 100644 index 0000000..39a6f25 --- /dev/null +++ b/app/views/madmin/chats/show.html.erb @@ -0,0 +1,111 @@ +<%= content_for :title, resource.display_name(@record) %> + +
+

+ <%= link_to resource.friendly_name.pluralize, resource.index_path %> + / + <%= resource.display_name(@record) %> +

+ + <% if resource.member_actions.any? %> +
+ <% resource.member_actions.each do |action| %> + <%= instance_exec(@record, &action) %> + <% end %> +
+ <% end %> +
+ +
+
+
+ User: + <%= link_to @record.user&.name || @record.user&.email, "/madmin/users/#{@record.user_id}", class: "font-medium text-dark-300 hover:text-dark-100" %> +
+
+ Model: + <%= @record.model&.name || 'Default' %> +
+
+ Created: + <%= @record.created_at.strftime("%B %d, %Y at %I:%M %p") %> +
+
+ Messages: + <%= @record.messages_count %> +
+
+ Tokens: + + In: <%= @record.messages.sum { |m| m.input_tokens.to_i } %> / Out: <%= @record.messages.sum { |m| m.output_tokens.to_i } %> + +
+
+ Total Cost: + <%= @record.formatted_total_cost || 'N/A' %> +
+
+
+ +
+
+

Conversation

+
+
+
+ <% @record.messages.each do |message| %> +
+ <% if message.role == 'user' %> +
+
+
+ <% if message.attachments.any? %> +
+ <% message.attachments.each do |attachment| %> + <% if attachment.content_type.start_with?("image/") && attachment.variable? %> + + <% elsif attachment.content_type.start_with?("image/") %> + + <% else %> + + <% end %> + <% end %> +
+ <% end %> +
<%= message.content %>
+
+
<%= message.created_at.strftime("%b %d, %Y at %I:%M %p") %>
+
+
+ <% elsif message.role == 'system' %> +
+
+
+ System: <%= message.content.truncate(200) %> +
+
<%= message.created_at.strftime("%b %d, %Y at %I:%M %p") %>
+
+
+ <% else %> +
+
<%= markdown(message.content) %>
+
+ <%= message.created_at.strftime("%b %d, %Y at %I:%M %p") %> + <% if message.formatted_cost %> + <%= message.formatted_cost %> + <% end %> +
+
+ <% end %> +
+ <% end %> +
+
+
diff --git a/app/views/madmin/comments/index.html.erb b/app/views/madmin/comments/index.html.erb new file mode 100644 index 0000000..899cbb5 --- /dev/null +++ b/app/views/madmin/comments/index.html.erb @@ -0,0 +1,76 @@ +<%= content_for :title, "Comments" %> + +
+

Comments

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search comments...", class: "form-input" %> +
+ +
+ <%= f.label :published, "Status", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.select :published, options_for_select([["All", ""], ["Published", "true"], ["Hidden", "false"]], params[:published]), {}, class: "form-input" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? || params[:published].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + + <% end %> + +
CommentAuthorPost<%= sortable :published, "Status" %><%= sortable :created_at, "Date" %>
+ <%= link_to resource.show_path(record), class: "block truncate" do %> + <%= record.body.truncate(80) %> + <% end %> + + <%= link_to record.user.display_name, "/madmin/users/#{record.user.id}", class: "text-accent-400 hover:text-accent-300" %> + + <%= link_to record.post.title.truncate(40), "/madmin/posts/#{record.post.id}", class: "truncate block text-accent-400 hover:text-accent-300" %> + + <% if record.published? %> + Published + <% else %> + Hidden + <% end %> + <%= record.created_at.strftime("%-d %b %Y") %>
+
+ + diff --git a/app/views/madmin/comments/show.html.erb b/app/views/madmin/comments/show.html.erb new file mode 100644 index 0000000..d4a5ddc --- /dev/null +++ b/app/views/madmin/comments/show.html.erb @@ -0,0 +1,52 @@ +<%= content_for :title, "Comment" %> + +
+

+ <%= link_to "Comments", resource.index_path %> + / + Comment +

+
+ <%= link_to "Edit", resource.edit_path(@record), class: "btn btn-secondary" %> +
+
+ +
+
+
+
<%= @record.body %>
+
+ <% if @record.published? %> + Published + <% else %> + Hidden + <% end %> + <%= @record.created_at.strftime("%-d %B %Y at %H:%M") %> +
+
+
+
+ +
+
+

Details

+
+
+ + + + + + + + + + + + + + + +
ID<%= @record.id %>
Author<%= link_to @record.user.display_name, "/madmin/users/#{@record.user.id}", class: "text-accent-400 hover:text-accent-300" %>
Post<%= link_to @record.post.title, "/madmin/posts/#{@record.post.id}", class: "text-accent-400 hover:text-accent-300" %>
Published<%= @record.published? ? "Yes" : "No" %>
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
Updated<%= @record.updated_at.strftime("%-d %B %Y at %H:%M") %>
+
+
diff --git a/app/views/madmin/dashboard/show.html.erb b/app/views/madmin/dashboard/show.html.erb new file mode 100644 index 0000000..943e82b --- /dev/null +++ b/app/views/madmin/dashboard/show.html.erb @@ -0,0 +1,299 @@ +
+
+

Dashboard

+

Overview of your application data and activity

+
+ +
+
+
+
+ <%= inline_svg "icons/dollar.svg", class: "w-6 h-6 text-white" %> +
+
+

$<%= number_with_precision(@metrics[:total_cost], precision: 2) %>

+

Total AI Cost

+
+ +
+
+
+ <%= inline_svg "icons/lightning.svg", class: "w-6 h-6 text-amber-400" %> +
+
+

<%= number_with_delimiter(@metrics[:total_tokens]) %>

+

AI Tokens

+
+ +
+
+
+ <%= inline_svg "icons/chat.svg", class: "w-6 h-6 text-purple-400" %> +
+ +<%= @metrics[:recent_chats] %> +
+

<%= number_with_delimiter(@metrics[:total_chats]) %>

+

Total Chats

+
+ +
+
+
+ <%= inline_svg "icons/users.svg", class: "w-6 h-6 text-blue-400" %> +
+ +<%= @metrics[:recent_users] %> +
+

<%= number_with_delimiter(@metrics[:total_users]) %>

+

Total Users

+
+ +
+
+
+ <%= inline_svg "icons/github.svg", class: "w-6 h-6 text-indigo-400" %> +
+
+

<%= number_with_delimiter(@metrics[:total_projects]) %>

+

Projects

+
+ +
+
+
+ <%= inline_svg "icons/check-circle.svg", class: "w-6 h-6 text-pink-400" %> +
+ <%= @metrics[:published_testimonials] %> pub +
+

<%= number_with_delimiter(@metrics[:total_testimonials]) %>

+

Testimonials

+
+
+ +
+
+
+

Cost Overview

+ Last 7 days +
+
+ +
+
+ +
+
+

Subscriptions

+ <%= link_to "Teams", "/madmin/teams", class: "text-sm text-dark-400 hover:text-dark-200 font-medium" %> +
+
+ <% sub_total = @subscription_stats.values.sum.to_f %> + <% [ + ["Active", @subscription_stats[:active], "bg-emerald-500", "text-emerald-400"], + ["Trialing", @subscription_stats[:trialing], "bg-blue-500", "text-blue-400"], + ["Past Due", @subscription_stats[:past_due], "bg-amber-500", "text-amber-400"], + ["Canceled", @subscription_stats[:canceled], "bg-red-500", "text-red-400"], + ["None", @subscription_stats[:none], "bg-dark-500", "text-dark-400"] + ].each do |label, count, bar_color, text_color| %> +
+
+ <%= label %> + <%= count %> +
+
+
+
+
+ <% end %> +
+
+
+
+

Monthly Revenue

+ <% if @subscription_revenue[:available] %> +

$<%= number_with_precision(@subscription_revenue[:mrr], precision: 2) %>

+ <% else %> +

Configure Stripe

+ <% end %> +
+
+

Total Revenue

+ <% if @subscription_revenue[:available] %> +

$<%= number_with_precision(@subscription_revenue[:total], precision: 2) %>

+ <% else %> +

Configure Stripe

+ <% end %> +
+
+
+
+
+ +
+
+
+

Recent Teams

+ <%= link_to "View all", "/madmin/teams", class: "text-sm text-dark-400 hover:text-dark-200 font-medium" %> +
+
+ <% if @recent_teams.any? %> + <% @recent_teams.each do |team| %> + <%= link_to "/madmin/teams/#{team.slug}", class: "block p-3 bg-dark-800/50 rounded-lg hover:bg-dark-800 transition-colors" do %> +
+ <%= team.name %> + <% status = team.subscription_status || "none" %> + <% badge_class = case status + when "active" then "bg-emerald-500/15 text-emerald-400" + when "trialing" then "bg-blue-500/15 text-blue-400" + when "past_due" then "bg-amber-500/15 text-amber-400" + when "canceled" then "bg-red-500/15 text-red-400" + else "bg-dark-700 text-dark-400" + end %> + + <%= status.titleize %> + +
+
+ <%= team.memberships.size %> members + · + <%= team.chats.size %> chats + · + <%= time_ago_in_words(team.created_at) %> ago +
+ <% end %> + <% end %> + <% else %> +
+

No teams yet

+
+ <% end %> +
+
+ +
+
+

Recent Users

+ <%= link_to "View all", "/madmin/users", class: "text-sm text-dark-400 hover:text-dark-200 font-medium" %> +
+
+ <% if @recent_users.any? %> + <% @recent_users.each do |user| %> + <%= link_to "/madmin/users/#{user.id}", class: "block p-3 bg-dark-800/50 rounded-lg hover:bg-dark-800 transition-colors" do %> +
+ <%= user.name || "Unnamed" %> + <%= time_ago_in_words(user.created_at) %> ago +
+
+ <%= user.email %> + · + <%= user.memberships.size %> teams +
+ <% end %> + <% end %> + <% else %> +
+

No users yet

+
+ <% end %> +
+
+ +
+
+

Recent Chats

+ <%= link_to "View all", "/madmin/chats", class: "text-sm text-dark-400 hover:text-dark-200 font-medium" %> +
+
+ <% if @recent_chats.any? %> + <% @recent_chats.each do |chat| %> + <%= link_to "/madmin/chats/#{chat.id}", class: "block p-3 bg-dark-800/50 rounded-lg hover:bg-dark-800 transition-colors" do %> +
+ <%= chat.user&.name || chat.user&.email || "Unknown" %> + $<%= number_with_precision(chat.total_cost || 0, precision: 2) %> +
+
<%= chat.messages.min_by(&:created_at)&.content&.truncate(70) || "Empty chat" %>
+
+ <%= chat.model&.name || "Default" %> + · + <%= chat.messages_count %> msgs + · + <%= time_ago_in_words(chat.created_at) %> ago +
+ <% end %> + <% end %> + <% else %> +
+

No chats yet

+
+ <% end %> +
+
+
+
+ + + diff --git a/app/views/madmin/fields/gravatar_field/_form.html.erb b/app/views/madmin/fields/gravatar_field/_form.html.erb new file mode 100644 index 0000000..8a0f0af --- /dev/null +++ b/app/views/madmin/fields/gravatar_field/_form.html.erb @@ -0,0 +1 @@ +<%= form.hidden_field field.attribute_name %> diff --git a/app/views/madmin/fields/gravatar_field/_index.html.erb b/app/views/madmin/fields/gravatar_field/_index.html.erb new file mode 100644 index 0000000..133db4a --- /dev/null +++ b/app/views/madmin/fields/gravatar_field/_index.html.erb @@ -0,0 +1,8 @@ +
+ <%= field.value %> + <%= field.value %> +
diff --git a/app/views/madmin/fields/gravatar_field/_show.html.erb b/app/views/madmin/fields/gravatar_field/_show.html.erb new file mode 100644 index 0000000..f7ace2a --- /dev/null +++ b/app/views/madmin/fields/gravatar_field/_show.html.erb @@ -0,0 +1,8 @@ +
+ <%= field.value %> + <%= field.value %> +
diff --git a/app/views/madmin/fields/json_field/_form.html.erb b/app/views/madmin/fields/json_field/_form.html.erb new file mode 100644 index 0000000..6a5c581 --- /dev/null +++ b/app/views/madmin/fields/json_field/_form.html.erb @@ -0,0 +1,7 @@ +<% val = field.value(record) %> +<%= form.text_area field.attribute_name, + value: val ? JSON.pretty_generate(val) : "{}", + rows: 10, + class: "form-control font-mono text-sm", + placeholder: "Enter valid JSON" %> +

Enter valid JSON format

diff --git a/app/views/madmin/fields/json_field/_index.html.erb b/app/views/madmin/fields/json_field/_index.html.erb new file mode 100644 index 0000000..39fa97a --- /dev/null +++ b/app/views/madmin/fields/json_field/_index.html.erb @@ -0,0 +1,5 @@ +<% if field.value(record).present? %> + <%= truncate(field.formatted_json(record), length: 50) %> +<% else %> + +<% end %> diff --git a/app/views/madmin/fields/json_field/_show.html.erb b/app/views/madmin/fields/json_field/_show.html.erb new file mode 100644 index 0000000..0bd25da --- /dev/null +++ b/app/views/madmin/fields/json_field/_show.html.erb @@ -0,0 +1 @@ +
<%= field.formatted_json(record) %>
diff --git a/app/views/madmin/languages/index.html.erb b/app/views/madmin/languages/index.html.erb new file mode 100644 index 0000000..54a4932 --- /dev/null +++ b/app/views/madmin/languages/index.html.erb @@ -0,0 +1,64 @@ +<%= content_for :title, "Languages" %> + +
+

Languages

+ +
+ + + <% if params[:q].present? %> + <%= link_to resource.index_path, class: "btn btn-secondary" do %> + Clear + <% end %> + <% end %> + + <%= button_to main_app.sync_madmin_languages_path, method: :post, class: "btn btn-primary" do %> + Sync Locale Files + <% end %> +
+
+ +
+ + + + + + + + + + + + <% @records.each do |language| %> + + + + + + + <% end %> + +
<%= sortable :name, "Name" %><%= sortable :native_name, "Native Name" %><%= sortable :code, "Code" %><%= sortable :enabled, "Enabled" %>
+ <%= link_to language.name, main_app.madmin_language_path(language), class: "font-medium hover:underline" %> + <%= language.native_name %><%= language.code %> + <%= button_to main_app.toggle_madmin_language_path(language), method: :patch, class: "inline-flex" do %> + + <%= language.enabled? ? "Enabled" : "Disabled" %> + + <% end %> +
+
+ + diff --git a/app/views/madmin/mail/edit.html.erb b/app/views/madmin/mail/edit.html.erb new file mode 100644 index 0000000..7cb4989 --- /dev/null +++ b/app/views/madmin/mail/edit.html.erb @@ -0,0 +1,50 @@ +
+
+

<%= t(".heading") %>

+
+ + <%= form_with model: @setting, url: main_app.madmin_mail_path, method: :patch, class: "space-y-6" do |form| %> +
+

<%= t(".general") %>

+
+
+ <%= form.label :mail_from, t(".mail_from"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.email_field :mail_from, + placeholder: "noreply@example.com", + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 text-sm" %> +
+
+
+ +
+

<%= t(".smtp") %>

+
+
+ <%= form.label :smtp_address, t(".smtp_address"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :smtp_address, + placeholder: "smtp.postmarkapp.com", + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 text-sm" %> +
+
+ <%= form.label :smtp_username, t(".smtp_username"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :smtp_username, + placeholder: t(".smtp_username_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 text-sm" %> +
+
+ <%= form.label :smtp_password, t(".smtp_password"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.password_field :smtp_password, + value: @setting.smtp_password, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+
+ +
+ <%= form.submit t(".save"), + class: "bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-6 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-blue-500/25" %> + <%= link_to t(".cancel"), main_app.madmin_mail_path, class: "text-dark-400 hover:text-dark-300 transition-colors" %> +
+ <% end %> +
diff --git a/app/views/madmin/mail/show.html.erb b/app/views/madmin/mail/show.html.erb new file mode 100644 index 0000000..a1c9b18 --- /dev/null +++ b/app/views/madmin/mail/show.html.erb @@ -0,0 +1,66 @@ +
+
+

<%= t(".heading") %>

+ <%= link_to t(".edit_button"), main_app.edit_madmin_mail_path, class: "inline-flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-dark-200 rounded-lg transition-colors" %> +
+ +
+

<%= t(".general") %>

+
+
+
<%= t(".mail_from") %>
+
+ <% if @setting.mail_from.present? %> + + <%= @setting.mail_from %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
+ +
+

<%= t(".smtp") %>

+
+
+
<%= t(".smtp_address") %>
+
+ <% if @setting.smtp_address.present? %> + + <%= @setting.smtp_address %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
<%= t(".smtp_username") %>
+
+ <% if @setting.smtp_username.present? %> + + <%= @setting.smtp_username %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
<%= t(".smtp_password") %>
+
+ <% if @setting.smtp_password.present? %> + + <%= mask_secret(@setting.smtp_password) %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
+
diff --git a/app/views/madmin/messages/index.html.erb b/app/views/madmin/messages/index.html.erb new file mode 100644 index 0000000..6e04237 --- /dev/null +++ b/app/views/madmin/messages/index.html.erb @@ -0,0 +1,88 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search messages...", class: "form-input" %> +
+ +
+ <%= f.label :role, class: "block text-sm font-medium text-dark-300 mb-1" %> + <% roles = Message.distinct.order(:role).pluck(:role) %> + <%= f.select :role, + options_for_select([["All roles", ""]] + roles.map { |r| [r.titleize, r] }, params[:role]), + {}, + class: "form-select" %> +
+ +
+ <%= f.label :created_at_from, "From:", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.date_field :created_at_from, value: params[:created_at_from], class: "form-input" %> +
+ +
+ <%= f.label :created_at_to, "To:", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.date_field :created_at_to, value: params[:created_at_to], class: "form-input" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? || params[:role].present? || params[:created_at_from].present? || params[:created_at_to].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + + <% end %> + +
<%= sortable :content, "Message" %><%= sortable :chat_id, "Chat" %><%= sortable :role, "Role" %><%= sortable :cost, "Cost" %><%= sortable :created_at, "Created" %>
+ <%= link_to resource.show_path(record), class: "block truncate" do %> + <%= record.content&.truncate(80) || "[No content]" %> + <% end %> + + <%= link_to "/madmin/chats/#{record.chat_id}", class: "block truncate hover:underline" do %> + <%= record.chat&.messages&.order(:created_at)&.first&.content&.truncate(40) || "Chat ##{record.chat_id.to_s[0..7]}" %> + <% end %> + + + <%= record.role %> + + <%= record.formatted_cost || "-" %><%= record.created_at.strftime("%-d %B %Y") %>
+
+ + diff --git a/app/views/madmin/messages/show.html.erb b/app/views/madmin/messages/show.html.erb new file mode 100644 index 0000000..db6bd4f --- /dev/null +++ b/app/views/madmin/messages/show.html.erb @@ -0,0 +1,108 @@ +<%= content_for :title, resource.display_name(@record) %> + +
+

+ <%= link_to resource.friendly_name.pluralize, resource.index_path %> + / + <%= resource.display_name(@record) %> +

+ + <% if resource.member_actions.any? %> +
+ <% resource.member_actions.each do |action| %> + <%= instance_exec(@record, &action) %> + <% end %> +
+ <% end %> +
+ +
+
+
+ Chat: + <%= link_to @record.chat_id, "/madmin/chats/#{@record.chat_id}", class: "text-dark-300 hover:text-dark-100" %> +
+
+ Role: + + <%= @record.role %> + +
+
+ Created: + <%= @record.created_at.strftime("%B %d, %Y at %I:%M %p") %> +
+
+ Tokens: + + <% if @record.input_tokens.present? || @record.output_tokens.present? %> + In: <%= @record.input_tokens || 0 %> / Out: <%= @record.output_tokens || 0 %> + <% else %> + N/A + <% end %> + +
+
+ Cost: + <%= @record.formatted_cost || 'N/A' %> +
+
+
+ +
+
+

Message Content

+
+
+ <% if @record.role == 'user' %> +
+
+ <% if @record.attachments.any? %> +
+ <% @record.attachments.each do |attachment| %> + <% if attachment.content_type.start_with?("image/") && attachment.variable? %> + + <% elsif attachment.content_type.start_with?("image/") %> + + <% else %> + + <% end %> + <% end %> +
+ <% end %> +
<%= @record.content %>
+
+
+ <% elsif @record.role == 'system' %> +
+ System Prompt: +
<%= @record.content %>
+
+ <% else %> +
<%= markdown(@record.content) %>
+ <% end %> +
+
+ +<% if @record.tool_calls.any? %> +
+
+

Tool Calls

+
+
+ <% @record.tool_calls.each do |tool_call| %> +
+
<%= tool_call.name %>
+
<%= JSON.pretty_generate(tool_call.arguments) rescue tool_call.arguments %>
+
+ <% end %> +
+
+<% end %> diff --git a/app/views/madmin/models/index.html.erb b/app/views/madmin/models/index.html.erb new file mode 100644 index 0000000..aa63bd7 --- /dev/null +++ b/app/views/madmin/models/index.html.erb @@ -0,0 +1,86 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+ +
+ <%= button_to "Refresh Models from RubyLLM", + "/madmin/models/refresh_all", + method: :post, + class: "btn btn-primary", + data: { turbo_confirm: "Refresh all models from RubyLLM?" } %> + + <%= link_to resource.new_path, class: "btn btn-secondary" do %> + New <%= tag.span resource.friendly_name, class: "hidden md:inline-block" %> + <% end %> +
+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search models...", class: "form-input" %> +
+ +
+ <%= f.label :provider, class: "block text-sm font-medium text-dark-300 mb-1" %> + <% providers = Model.distinct.order(:provider).pluck(:provider) %> + <%= f.select :provider, + options_for_select([["All providers", ""]] + providers.map { |p| [p.titleize, p] }, params[:provider]), + {}, + class: "form-select" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? || params[:provider].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + + <% end %> + +
<%= sortable :provider, "Provider" %><%= sortable :family, "Family" %><%= sortable :name, "Name" %><%= sortable :chats_count, "Chats" %><%= sortable :total_cost, "Total Cost" %>
+ + <%= record.provider %> + + <%= record.family %> + <%= link_to resource.show_path(record), class: "font-medium hover:underline" do %> + <%= record.name %> + <% end %> + <%= record.chats_count %><%= record.formatted_total_cost || "-" %>
+
+ + diff --git a/app/views/madmin/models/show.html.erb b/app/views/madmin/models/show.html.erb new file mode 100644 index 0000000..7ef3c10 --- /dev/null +++ b/app/views/madmin/models/show.html.erb @@ -0,0 +1,138 @@ +<%= content_for :title, @record.name %> + +
+

+ <%= link_to resource.friendly_name.pluralize, resource.index_path %> + / + <%= @record.name %> +

+ +
+ <%= link_to "Edit", resource.edit_path(@record), class: "btn btn-secondary" %> +
+
+ +
+
+
+ <%= @record.name[0..1].upcase %> +
+
+

<%= @record.name %>

+
+ <%= @record.provider.titleize %> + <% if @record.family.present? %> + <%= @record.family %> + <% end %> +
+

Model ID: <%= @record.model_id %>

+
+
+
+ +
+
+
Context Window
+
<%= number_with_delimiter(@record.context_window) || "-" %>
+
+
+
Max Output
+
<%= number_with_delimiter(@record.max_output_tokens) || "-" %>
+
+
+
Total Chats
+
<%= @record.chats_count %>
+
+
+
Total Cost
+
<%= @record.formatted_total_cost || "$0.00" %>
+
+
+ +<% if @record.capabilities.present? && @record.capabilities.any? %> +
+
Capabilities
+
+ <% @record.capabilities.each do |capability| %> + <%= capability %> + <% end %> +
+
+<% end %> + +
+
+

Details

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Model ID<%= @record.model_id %>
Name<%= @record.name %>
Provider<%= @record.provider %>
Family<%= @record.family || "-" %>
Context Window<%= number_with_delimiter(@record.context_window) || "-" %>
Max Output Tokens<%= number_with_delimiter(@record.max_output_tokens) || "-" %>
Knowledge Cutoff<%= @record.knowledge_cutoff&.strftime("%-d %B %Y") || "-" %>
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
+
+
+ +<% if @record.modalities.present? %> +
+
+

Modalities

+
+
+
<%= JSON.pretty_generate(@record.modalities) rescue @record.modalities %>
+
+
+<% end %> + +<% if @record.pricing.present? && @record.pricing.any? %> +
+
+

Pricing

+
+
+
<%= JSON.pretty_generate(@record.pricing) rescue @record.pricing %>
+
+
+<% end %> + +<% if @record.metadata.present? && @record.metadata.any? %> +
+
+

Metadata

+
+
+
<%= JSON.pretty_generate(@record.metadata) rescue @record.metadata %>
+
+
+<% end %> diff --git a/app/views/madmin/posts/index.html.erb b/app/views/madmin/posts/index.html.erb new file mode 100644 index 0000000..5f294c0 --- /dev/null +++ b/app/views/madmin/posts/index.html.erb @@ -0,0 +1,121 @@ +<%= content_for :title, "Posts" %> + +
+

Posts

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search posts...", class: "form-input" %> +
+ +
+ <%= f.label :post_type, "Type", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.select :post_type, options_for_select([["All types", ""], ["Article", "article"], ["Link", "link"], ["Success Story", "success_story"]], params[:post_type]), {}, class: "form-input" %> +
+ +
+ <%= f.label :published, "Status", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.select :published, options_for_select([["All", ""], ["Published", "true"], ["Draft", "false"]], params[:published]), {}, class: "form-input" %> +
+ +
+ +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? || params[:post_type].present? || params[:published].present? || params[:needs_review].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + + + <% end %> + +
<%= sortable :title, "Post" %><%= sortable :post_type, "Type" %><%= sortable :published, "Status" %><%= sortable :comments_count, "Comments" %><%= sortable :reports_count, "Reports" %><%= sortable :created_at, "Created" %>
+ <%= link_to resource.show_path(record), class: "block" do %> +
<%= record.title %>
+
+ by <%= record.user.display_name %> + <% if record.category %> + in <%= record.category.name %> + <% end %> +
+ <% end %> +
+ <% type_class = case record.post_type + when "article" then "bg-blue-500/15 text-blue-400" + when "link" then "bg-emerald-500/15 text-emerald-400" + when "success_story" then "bg-amber-500/15 text-amber-400" + end %> + + <%= record.post_type.titleize %> + + + <% if record.needs_admin_review? %> + + Needs Review + + <% elsif record.published? %> + + Published + + <% else %> + + Draft + + <% end %> + + <% if record.comments_count > 0 %> + <%= record.comments_count %> + <% else %> + 0 + <% end %> + + <% if record.reports_count > 0 %> + <%= record.reports_count %> + <% else %> + 0 + <% end %> + <%= record.created_at.strftime("%-d %b %Y") %>
+
+ + diff --git a/app/views/madmin/posts/show.html.erb b/app/views/madmin/posts/show.html.erb new file mode 100644 index 0000000..53f6377 --- /dev/null +++ b/app/views/madmin/posts/show.html.erb @@ -0,0 +1,263 @@ +<%= content_for :title, @record.title.truncate(40) %> + +
+

+ <%= link_to "Posts", resource.index_path %> + / + <%= @record.title.truncate(50) %> +

+
+ <%= link_to "Edit", resource.edit_path(@record), class: "btn btn-secondary" %> +
+
+ +<%# Status banner for posts needing review %> +<% if @record.needs_admin_review? %> +
+
+ <%= inline_svg "icons/shield.svg", class: "w-5 h-5 text-red-400 flex-shrink-0" %> +
+
This post needs admin review
+
Auto-hidden after receiving <%= @record.reports_count %> report(s). Review and restore or archive.
+
+
+
+<% end %> + +<%# Post header card %> +
+
+
+

<%= @record.title %>

+
+ <% type_class = case @record.post_type + when "article" then "bg-blue-500/15 text-blue-400" + when "link" then "bg-emerald-500/15 text-emerald-400" + when "success_story" then "bg-amber-500/15 text-amber-400" + end %> + + <%= @record.post_type.titleize %> + + <% if @record.published? %> + Published + <% else %> + Draft + <% end %> + <% if @record.pin_position.present? %> + Pinned #<%= @record.pin_position %> + <% end %> +
+
+ by <%= @record.user.display_name %> + <% if @record.category %> + in <%= @record.category.name %> + <% end %> + · <%= @record.created_at.strftime("%-d %B %Y at %H:%M") %> +
+
+
+
+ +<%# Stats row %> +
+
+
Comments
+
<%= @record.comments_count %>
+
+
+
Reports
+
<%= @record.reports_count %>
+
+
+
Type
+
<%= @record.post_type.titleize %>
+
+
+
Slug
+
<%= @record.slug %>
+
+
+ +<%# URL for links %> +<% if @record.url.present? %> + +<% end %> + +<%# Summary %> +<% if @record.summary.present? %> +
+
+

AI Summary

+
+
<%= @record.summary %>
+
+<% end %> + +<%# Content %> +<% if @record.content.present? %> +
+
+

Content

+
+
+ <%= simple_format(@record.content.truncate(2000)) %> +
+
+<% end %> + +<%# Details table %> +
+
+

Details

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID<%= @record.id %>
Author + <%= link_to "/madmin/users/#{@record.user.id}", class: "text-accent-400 hover:text-accent-300" do %> + <%= @record.user.display_name %> + <% end %> +
Category + <% if @record.category %> + <%= link_to @record.category.name, "/madmin/categories/#{@record.category.id}", class: "text-accent-400 hover:text-accent-300" %> + <% else %> + None + <% end %> +
Tags + <% if @record.tags.any? %> +
+ <% @record.tags.each do |tag| %> + <%= link_to tag.name, "/madmin/tags/#{tag.id}", class: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-dark-600 text-dark-300 hover:text-dark-100" %> + <% end %> +
+ <% else %> + No tags + <% end %> +
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
Updated<%= @record.updated_at.strftime("%-d %B %Y at %H:%M") %>
+
+
+ +<%# Comments %> +<% comments = @record.comments.includes(:user).order(created_at: :desc) %> +<% if comments.any? %> +
+
+

Comments (<%= comments.size %>)

+
+
+ + + + + + + + + + + <% comments.first(10).each do |comment| %> + + + + + + + <% end %> + +
UserCommentStatusDate
+ <%= link_to comment.user.display_name, "/madmin/users/#{comment.user.id}", class: "text-accent-400 hover:text-accent-300" %> + + <%= link_to "/madmin/comments/#{comment.id}", class: "block truncate" do %> + <%= comment.body.truncate(80) %> + <% end %> + + <% if comment.published? %> + Published + <% else %> + Hidden + <% end %> + <%= comment.created_at.strftime("%-d %b %Y") %>
+
+ <% if comments.size > 10 %> +
+ <%= link_to "View all #{comments.size} comments", "/madmin/comments?q=#{@record.title.first(20)}", class: "text-sm text-accent-400 hover:text-accent-300" %> +
+ <% end %> +
+<% end %> + +<%# Reports %> +<% reports = @record.reports.includes(:user).order(created_at: :desc) %> +<% if reports.any? %> +
+
+

Reports (<%= reports.size %>)

+
+
+ + + + + + + + + + + <% reports.each do |report| %> + + + + + + + <% end %> + +
ReporterReasonDescriptionDate
+ <%= link_to report.user.display_name, "/madmin/users/#{report.user.id}", class: "text-accent-400 hover:text-accent-300" %> + + <% reason_class = case report.reason + when "spam" then "bg-amber-500/15 text-amber-400" + when "inappropriate", "harassment" then "bg-red-500/15 text-red-400" + when "off_topic" then "bg-blue-500/15 text-blue-400" + when "misinformation" then "bg-orange-500/15 text-orange-400" + else "bg-dark-600 text-dark-400" + end %> + + <%= report.reason.titleize %> + + <%= report.description.presence || "-" %><%= report.created_at.strftime("%-d %b %Y") %>
+
+
+<% end %> diff --git a/app/views/madmin/prices/show.html.erb b/app/views/madmin/prices/show.html.erb new file mode 100644 index 0000000..493ade5 --- /dev/null +++ b/app/views/madmin/prices/show.html.erb @@ -0,0 +1,40 @@ +
+
+

<%= t(".heading") %>

+ <%= button_to t(".sync_prices"), main_app.sync_madmin_prices_path, method: :post, class: "inline-flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-dark-200 rounded-lg transition-colors text-sm font-medium" %> +
+ +
+ <% if @setting.stripe_secret_key.blank? %> +
+

<%= t(".stripe_not_configured") %>

+

<%= t(".stripe_not_configured_hint") %>

+
+ <% elsif @prices.any? %> + + + + + + + + + + + <% @prices.each do |price| %> + + + + + + + <% end %> + +
<%= t(".price_product") %><%= t(".price_amount") %><%= t(".price_interval") %><%= t(".price_id") %>
<%= price.product_name %><%= price.formatted_amount %><%= price.formatted_interval %><%= price.id %>
+ <% else %> +
+

<%= t(".no_prices") %>

+
+ <% end %> +
+
diff --git a/app/views/madmin/projects/index.html.erb b/app/views/madmin/projects/index.html.erb new file mode 100644 index 0000000..50bf44e --- /dev/null +++ b/app/views/madmin/projects/index.html.erb @@ -0,0 +1,92 @@ +<%= content_for :title, "Projects" %> + +
+

Projects

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search projects...", class: "form-input" %> +
+ +
+ + +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? || params[:hidden].present? || params[:archived].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + + + <% end %> + +
<%= sortable :name, "Project" %>Owner<%= sortable :stars, "Stars" %><%= sortable :forks_count, "Forks" %>Status<%= sortable :pushed_at, "Last Push" %>
+ <%= link_to resource.show_path(record), class: "block" do %> +
<%= record.name %>
+ <% if record.description.present? %> +
<%= record.description.truncate(80) %>
+ <% end %> + <% end %> +
+ <%= link_to record.user.display_name, "/madmin/users/#{record.user.id}", class: "text-accent-400 hover:text-accent-300" %> + +
+ + <%= number_with_delimiter(record.stars) %> +
+
<%= record.forks_count %> + <% if record.archived? %> + Archived + <% elsif record.hidden? %> + Hidden + <% else %> + Visible + <% end %> + <%= record.pushed_at&.strftime("%-d %b %Y") || "-" %>
+
+ + diff --git a/app/views/madmin/projects/show.html.erb b/app/views/madmin/projects/show.html.erb new file mode 100644 index 0000000..13ca06f --- /dev/null +++ b/app/views/madmin/projects/show.html.erb @@ -0,0 +1,142 @@ +<%= content_for :title, @record.name %> + +
+

+ <%= link_to "Projects", resource.index_path %> + / + <%= @record.name %> +

+
+ +
+
+
+

<%= @record.name %>

+ <% if @record.description.present? %> +

<%= @record.description %>

+ <% end %> +
+ + <%= inline_svg "icons/github.svg", class: "w-4 h-4" %> + View on GitHub + <%= inline_svg "icons/external-link.svg", class: "w-3 h-3" %> + + <% if @record.archived? %> + Archived + <% elsif @record.hidden? %> + Hidden + <% else %> + Visible + <% end %> +
+
+
+
+ +
+
+
Stars
+
<%= number_with_delimiter(@record.stars) %>
+
+
+
Stars Gained
+
+<%= @record.stars_gained %>
+
+
+
Forks
+
<%= @record.forks_count %>
+
+
+
Size
+
<%= number_to_human_size(@record.size * 1024) %>
+
+
+ +<%# Topics %> +<% if @record.topics.any? %> +
+
Topics
+
+ <% @record.topics.each do |topic| %> + + <%= topic %> + + <% end %> +
+
+<% end %> + +<%# Details %> +
+
+

Details

+
+
+ + + + + + + + + + + + + + + + +
ID<%= @record.id %>
Owner<%= link_to @record.user.display_name, "/madmin/users/#{@record.user.id}", class: "text-accent-400 hover:text-accent-300" %>
GitHub URL + + <%= @record.github_url %> + +
Hidden<%= @record.hidden? ? "Yes" : "No" %>
Archived<%= @record.archived? ? "Yes" : "No" %>
Last Push<%= @record.pushed_at&.strftime("%-d %B %Y at %H:%M") || "-" %>
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
+
+
+ +<%# Star snapshots %> +<% snapshots = @record.star_snapshots.order(recorded_on: :desc).limit(14) %> +<% if snapshots.any? %> +
+
+

Star History (last 14 days)

+
+
+ + + + + + + + + + <% prev_stars = nil %> + <% snapshots.reverse.each do |snapshot| %> + + + + + + <% prev_stars = snapshot.stars %> + <% end %> + +
DateStarsChange
<%= snapshot.recorded_on.strftime("%-d %b %Y") %><%= number_with_delimiter(snapshot.stars) %> + <% if prev_stars %> + <% diff = snapshot.stars - prev_stars %> + <% if diff > 0 %> + +<%= diff %> + <% elsif diff < 0 %> + <%= diff %> + <% else %> + 0 + <% end %> + <% else %> + - + <% end %> +
+
+
+<% end %> diff --git a/app/views/madmin/providers/index.html.erb b/app/views/madmin/providers/index.html.erb new file mode 100644 index 0000000..77974df --- /dev/null +++ b/app/views/madmin/providers/index.html.erb @@ -0,0 +1,47 @@ +
+
+

<%= t(".heading") %>

+

<%= t(".description") %>

+
+ + <%= form_with url: main_app.madmin_providers_path, method: :patch, class: "space-y-6" do |form| %> + <% @providers.each do |provider, settings| %> +
+
+

<%= t(".providers.#{provider}", default: provider.titleize) %>

+ <% if ProviderCredential.configured?(provider) %> + + <% end %> +
+
+ <% settings.each do |setting| %> + <% credential = @credentials[[provider, setting]] %> + <% field_name = "providers[#{provider}][#{setting}]" %> + <% field_id = "providers_#{provider}_#{setting}" %> + <% is_secret = setting.end_with?("_key", "_secret", "_token") %> +
+ <%= label_tag field_id, t(".settings.#{setting}", default: setting.titleize), class: "block text-sm font-medium text-dark-200 mb-2" %> + <% if is_secret %> + <%= password_field_tag field_name, credential&.value, + id: field_id, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> + <% else %> + <%= text_field_tag field_name, credential&.value, + id: field_id, + placeholder: t(".placeholders.#{setting}", default: ""), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 text-sm" %> + <% end %> +
+ <% end %> +
+
+ <% end %> + + <%# Actions %> +
+ <%= submit_tag t(".save"), + class: "bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-6 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-blue-500/25" %> +
+ <% end %> +
diff --git a/app/views/madmin/reports/index.html.erb b/app/views/madmin/reports/index.html.erb new file mode 100644 index 0000000..516516a --- /dev/null +++ b/app/views/madmin/reports/index.html.erb @@ -0,0 +1,77 @@ +<%= content_for :title, "Reports" %> + +
+

Reports

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search reports...", class: "form-input" %> +
+ +
+ <%= f.label :reason, "Reason", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.select :reason, options_for_select([["All reasons", ""], ["Spam", "spam"], ["Inappropriate", "inappropriate"], ["Off Topic", "off_topic"], ["Harassment", "harassment"], ["Misinformation", "misinformation"], ["Other", "other"]], params[:reason]), {}, class: "form-input" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? || params[:reason].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + + <% end %> + +
PostReporter<%= sortable :reason, "Reason" %>Description<%= sortable :created_at, "Date" %>
+ <%= link_to record.post.title.truncate(50), "/madmin/posts/#{record.post.id}", class: "truncate block text-accent-400 hover:text-accent-300 font-medium" %> + + <%= link_to record.user.display_name, "/madmin/users/#{record.user.id}", class: "text-accent-400 hover:text-accent-300" %> + + <% reason_class = case record.reason + when "spam" then "bg-amber-500/15 text-amber-400" + when "inappropriate", "harassment" then "bg-red-500/15 text-red-400" + when "off_topic" then "bg-blue-500/15 text-blue-400" + when "misinformation" then "bg-orange-500/15 text-orange-400" + else "bg-dark-600 text-dark-400" + end %> + + <%= record.reason.titleize %> + + <%= record.description.presence || "-" %><%= record.created_at.strftime("%-d %b %Y") %>
+
+ + diff --git a/app/views/madmin/reports/show.html.erb b/app/views/madmin/reports/show.html.erb new file mode 100644 index 0000000..f485796 --- /dev/null +++ b/app/views/madmin/reports/show.html.erb @@ -0,0 +1,78 @@ +<%= content_for :title, "Report" %> + +
+

+ <%= link_to "Reports", resource.index_path %> + / + <%= @record.reason.titleize %> Report +

+
+ +
+
+
+ <% reason_class = case @record.reason + when "spam" then "bg-amber-500/15 text-amber-400" + when "inappropriate", "harassment" then "bg-red-500/15 text-red-400" + when "off_topic" then "bg-blue-500/15 text-blue-400" + when "misinformation" then "bg-orange-500/15 text-orange-400" + else "bg-dark-600 text-dark-400" + end %> + + <%= @record.reason.titleize %> + + <% if @record.description.present? %> +

<%= @record.description %>

+ <% end %> +
+ Reported by <%= @record.user.display_name %> + on <%= @record.created_at.strftime("%-d %B %Y at %H:%M") %> +
+
+
+
+ +
+
+

Reported Post

+
+
+
+
+ <%= link_to "/madmin/posts/#{@record.post.id}", class: "text-lg font-medium text-accent-400 hover:text-accent-300" do %> + <%= @record.post.title %> + <% end %> +
+ by <%= @record.post.user.display_name %> + · <%= @record.post.reports_count %> total report(s) + <% if @record.post.needs_admin_review? %> + · Needs review + <% end %> +
+
+
+
+
+ +
+
+

Details

+
+
+ + + + + + + + + + + + + + +
ID<%= @record.id %>
Reporter<%= link_to @record.user.display_name, "/madmin/users/#{@record.user.id}", class: "text-accent-400 hover:text-accent-300" %>
Post<%= link_to @record.post.title, "/madmin/posts/#{@record.post.id}", class: "text-accent-400 hover:text-accent-300" %>
Reason<%= @record.reason.titleize %>
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
+
+
diff --git a/app/views/madmin/settings/ai_models/edit.html.erb b/app/views/madmin/settings/ai_models/edit.html.erb new file mode 100644 index 0000000..b996766 --- /dev/null +++ b/app/views/madmin/settings/ai_models/edit.html.erb @@ -0,0 +1,50 @@ +
+
+

<%= t(".heading") %>

+
+ + <%= form_with model: @setting, url: main_app.madmin_settings_ai_models_path, method: :patch, class: "space-y-6" do |form| %> +
+

<%= t(".ai_models_hint", default_model: Setting::DEFAULT_AI_MODEL) %>

+
+
+ <%= form.label :default_ai_model, t(".default_ai_model"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :default_ai_model, + placeholder: Setting::DEFAULT_AI_MODEL, + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +

<%= t(".default_ai_model_hint") %>

+
+
+ <%= form.label :summary_model, t(".summary_model"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :summary_model, + placeholder: t(".uses_default"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+ <%= form.label :testimonial_model, t(".testimonial_model"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :testimonial_model, + placeholder: t(".uses_default"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+ <%= form.label :validation_model, t(".validation_model"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :validation_model, + placeholder: t(".uses_default"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+ <%= form.label :translation_model, t(".translation_model"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :translation_model, + placeholder: t(".uses_default"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+
+ +
+ <%= form.submit t(".save"), + class: "bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-6 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-blue-500/25" %> + <%= link_to t(".cancel"), main_app.madmin_settings_ai_models_path, class: "text-dark-400 hover:text-dark-300 transition-colors" %> +
+ <% end %> +
diff --git a/app/views/madmin/settings/ai_models/show.html.erb b/app/views/madmin/settings/ai_models/show.html.erb new file mode 100644 index 0000000..c822ff7 --- /dev/null +++ b/app/views/madmin/settings/ai_models/show.html.erb @@ -0,0 +1,46 @@ +
+
+

<%= t(".heading") %>

+ <%= link_to t(".edit_button"), main_app.edit_madmin_settings_ai_models_path, class: "inline-flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-dark-200 rounded-lg transition-colors" %> +
+ +
+
+
+
<%= t(".default_ai_model") %>
+
+ + <%= @setting.default_ai_model.presence || Setting::DEFAULT_AI_MODEL %> +
+
+
+
<%= t(".summary_model") %>
+
+ + <%= @setting.summary_model.presence || t(".uses_default") %> +
+
+
+
<%= t(".testimonial_model") %>
+
+ + <%= @setting.testimonial_model.presence || t(".uses_default") %> +
+
+
+
<%= t(".validation_model") %>
+
+ + <%= @setting.validation_model.presence || t(".uses_default") %> +
+
+
+
<%= t(".translation_model") %>
+
+ + <%= @setting.translation_model.presence || t(".uses_default") %> +
+
+
+
+
diff --git a/app/views/madmin/settings/edit.html.erb b/app/views/madmin/settings/edit.html.erb new file mode 100644 index 0000000..c590fbe --- /dev/null +++ b/app/views/madmin/settings/edit.html.erb @@ -0,0 +1,118 @@ +
+
+

<%= t(".heading") %>

+
+ + <%= form_with model: @setting, url: main_app.madmin_settings_path, method: :patch, class: "space-y-6" do |form| %> + <%# GitHub OAuth %> +
+

<%= t(".github") %>

+
+
+ <%= form.label :github_whyruby_client_id, t(".github_whyruby_client_id"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :github_whyruby_client_id, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+ <%= form.label :github_whyruby_client_secret, t(".github_whyruby_client_secret"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.password_field :github_whyruby_client_secret, + value: @setting.github_whyruby_client_secret, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+ <%= form.label :github_rubycommunity_client_id, t(".github_rubycommunity_client_id"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :github_rubycommunity_client_id, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+ <%= form.label :github_rubycommunity_client_secret, t(".github_rubycommunity_client_secret"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.password_field :github_rubycommunity_client_secret, + value: @setting.github_rubycommunity_client_secret, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+ <%= form.label :github_api_token, t(".github_api_token"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.password_field :github_api_token, + value: @setting.github_api_token, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +

<%= t(".github_api_token_hint") %>

+
+
+
+ + <%# Stripe %> +
+

<%= t(".stripe") %>

+
+
+ <%= form.label :stripe_secret_key, t(".stripe_secret_key"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.password_field :stripe_secret_key, + value: @setting.stripe_secret_key, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+ <%= form.label :stripe_publishable_key, t(".stripe_publishable_key"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :stripe_publishable_key, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+ <%= form.label :stripe_webhook_secret, t(".stripe_webhook_secret"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.password_field :stripe_webhook_secret, + value: @setting.stripe_webhook_secret, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+ <%= form.label :trial_days, t(".trial_days"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.number_field :trial_days, + min: 0, + placeholder: "30", + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 text-sm" %> +

<%= t(".trial_days_hint") %>

+
+
+
+ + <%# Litestream %> +
+

<%= t(".litestream") %>

+

<%= t(".litestream_restart_note") %>

+
+
+ <%= form.label :litestream_replica_bucket, t(".litestream_replica_bucket"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :litestream_replica_bucket, + placeholder: "my-bucket.s3.amazonaws.com", + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 text-sm" %> +
+
+ <%= form.label :litestream_replica_key_id, t(".litestream_replica_key_id"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.password_field :litestream_replica_key_id, + value: @setting.litestream_replica_key_id, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+ <%= form.label :litestream_replica_access_key, t(".litestream_replica_access_key"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.password_field :litestream_replica_access_key, + value: @setting.litestream_replica_access_key, + placeholder: t(".key_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500 font-mono text-sm" %> +
+
+
+ + <%# Actions %> +
+ <%= form.submit t(".save"), + class: "bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-6 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-blue-500/25" %> + <%= link_to t(".cancel"), main_app.madmin_settings_path, class: "text-dark-400 hover:text-dark-300 transition-colors" %> +
+ <% end %> +
diff --git a/app/views/madmin/settings/show.html.erb b/app/views/madmin/settings/show.html.erb new file mode 100644 index 0000000..bd41b06 --- /dev/null +++ b/app/views/madmin/settings/show.html.erb @@ -0,0 +1,164 @@ +
+
+

<%= t(".heading") %>

+ <%= link_to t(".edit_button"), main_app.edit_madmin_settings_path, class: "inline-flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-dark-200 rounded-lg transition-colors" %> +
+ + <%# GitHub OAuth %> +
+

<%= t(".github") %>

+
+
+
<%= t(".github_whyruby_client_id") %>
+
+ <% if @setting.github_whyruby_client_id.present? %> + + <%= mask_secret(@setting.github_whyruby_client_id) %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
<%= t(".github_whyruby_client_secret") %>
+
+ <% if @setting.github_whyruby_client_secret.present? %> + + <%= mask_secret(@setting.github_whyruby_client_secret) %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
<%= t(".github_rubycommunity_client_id") %>
+
+ <% if @setting.github_rubycommunity_client_id.present? %> + + <%= mask_secret(@setting.github_rubycommunity_client_id) %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
<%= t(".github_rubycommunity_client_secret") %>
+
+ <% if @setting.github_rubycommunity_client_secret.present? %> + + <%= mask_secret(@setting.github_rubycommunity_client_secret) %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
<%= t(".github_api_token") %>
+
+ <% if @setting.github_api_token.present? %> + + <%= mask_secret(@setting.github_api_token) %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
+ + <%# Stripe %> +
+

<%= t(".stripe") %>

+
+
+
<%= t(".stripe_secret_key") %>
+
+ <% if @setting.stripe_secret_key.present? %> + + <%= mask_secret(@setting.stripe_secret_key) %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
<%= t(".stripe_publishable_key") %>
+
+ <% if @setting.stripe_publishable_key.present? %> + + <%= mask_secret(@setting.stripe_publishable_key) %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
<%= t(".stripe_webhook_secret") %>
+
+ <% if @setting.stripe_webhook_secret.present? %> + + <%= mask_secret(@setting.stripe_webhook_secret) %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
<%= t(".trial_days") %>
+
<%= pluralize(@setting.trial_days, "day") %>
+
+
+
+ + <%# Litestream %> +
+

<%= t(".litestream") %>

+

<%= t(".litestream_restart_note") %>

+
+
+
<%= t(".litestream_replica_bucket") %>
+
+ <% if @setting.litestream_replica_bucket.present? %> + + <%= @setting.litestream_replica_bucket %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
<%= t(".litestream_replica_key_id") %>
+
+ <% if @setting.litestream_replica_key_id.present? %> + + <%= mask_secret(@setting.litestream_replica_key_id) %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
<%= t(".litestream_replica_access_key") %>
+
+ <% if @setting.litestream_replica_access_key.present? %> + + <%= mask_secret(@setting.litestream_replica_access_key) %> + <% else %> + + <%= t(".not_configured") %> + <% end %> +
+
+
+
+
diff --git a/app/views/madmin/tags/index.html.erb b/app/views/madmin/tags/index.html.erb new file mode 100644 index 0000000..9f5d27a --- /dev/null +++ b/app/views/madmin/tags/index.html.erb @@ -0,0 +1,59 @@ +<%= content_for :title, "Tags" %> + +
+

Tags

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search tags...", class: "form-input" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + <% @records.each do |record| %> + + + + + + <% end %> + +
<%= sortable :name, "Tag" %>Posts<%= sortable :created_at, "Created" %>
+ <%= link_to resource.show_path(record), class: "block" do %> + + <%= record.name %> + + <% end %> + <%= record.posts.size %><%= record.created_at.strftime("%-d %b %Y") %>
+
+ + diff --git a/app/views/madmin/tags/show.html.erb b/app/views/madmin/tags/show.html.erb new file mode 100644 index 0000000..9bc068f --- /dev/null +++ b/app/views/madmin/tags/show.html.erb @@ -0,0 +1,98 @@ +<%= content_for :title, @record.name %> + +
+

+ <%= link_to "Tags", resource.index_path %> + / + <%= @record.name %> +

+
+ <%= link_to "Edit", resource.edit_path(@record), class: "btn btn-secondary" %> +
+
+ +
+
+ + <%= @record.name %> + + Slug: <%= @record.slug %> +
+
+ +
+
+
Total Posts
+
<%= @record.posts.size %>
+
+
+
Published Posts
+
<%= @record.published_posts_count %>
+
+
+ +
+
+

Details

+
+
+ + + + + + + + +
ID<%= @record.id %>
Name<%= @record.name %>
Slug<%= @record.slug %>
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
Updated<%= @record.updated_at.strftime("%-d %B %Y at %H:%M") %>
+
+
+ +<%# Posts with this tag %> +<% tagged_posts = @record.posts.includes(:user, :category).order(created_at: :desc).limit(10) %> +<% if tagged_posts.any? %> +
+
+

Posts with this Tag

+
+
+ + + + + + + + + + + + <% tagged_posts.each do |post| %> + + + + + + + + <% end %> + +
TitleAuthorTypeStatusDate
+ <%= link_to post.title.truncate(60), "/madmin/posts/#{post.id}", class: "truncate block text-accent-400 hover:text-accent-300" %> + <%= post.user.display_name %> + <% type_class = case post.post_type + when "article" then "bg-blue-500/15 text-blue-400" + when "link" then "bg-emerald-500/15 text-emerald-400" + when "success_story" then "bg-amber-500/15 text-amber-400" + end %> + <%= post.post_type.titleize %> + + <% if post.published? %> + Published + <% else %> + Draft + <% end %> + <%= post.created_at.strftime("%-d %b %Y") %>
+
+
+<% end %> diff --git a/app/views/madmin/teams/index.html.erb b/app/views/madmin/teams/index.html.erb new file mode 100644 index 0000000..4d8afbf --- /dev/null +++ b/app/views/madmin/teams/index.html.erb @@ -0,0 +1,97 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search teams...", class: "form-input" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + + + + <% @records.each do |record| %> + + + + + + <% cost = record.chats.sum(&:total_cost) %> + + + + + <% end %> + +
<%= sortable :name, "Team" %><%= sortable :owner_name, "Owner" %><%= sortable :members_count, "Members" %><%= sortable :chats_count, "Chats" %><%= sortable :total_cost, "Cost" %><%= sortable :subscription_status, "Subscription" %><%= sortable :created_at, "Created" %>
+ <%= link_to resource.show_path(record) do %> +
<%= record.name %>
+
<%= record.slug %>
+ <% end %> +
+ <% owner = record.memberships.find { |m| m.role == "owner" } %> + <% if owner %> + <%= link_to "/madmin/users/#{owner.user.id}", class: "flex items-center gap-3" do %> + +
+
<%= owner.user.name.presence || "Unnamed" %>
+
<%= owner.user.email %>
+
+ <% end %> + <% else %> + No owner + <% end %> +
<%= record.memberships.size %><%= record.chats.size %> + <% if cost.zero? %>-<% elsif cost < 0.0001 %><$0.0001<% else %>$<%= '%.4f' % cost %><% end %> + + <% status = record.subscription_status || "none" %> + <% badge_class = case status + when "active" then "bg-emerald-500/15 text-emerald-400" + when "trialing" then "bg-blue-500/15 text-blue-400" + when "past_due" then "bg-amber-500/15 text-amber-400" + when "canceled" then "bg-red-500/15 text-red-400" + else "bg-dark-700 text-dark-400" + end %> + + <%= status.titleize %> + + <% if record.trialing? && record.trial_days_remaining > 0 %> + <%= record.trial_days_remaining %>d left + <% end %> + <%= record.created_at.strftime("%-d %b %Y") %>
+
+ + diff --git a/app/views/madmin/teams/show.html.erb b/app/views/madmin/teams/show.html.erb new file mode 100644 index 0000000..adcbe6a --- /dev/null +++ b/app/views/madmin/teams/show.html.erb @@ -0,0 +1,226 @@ +<%= content_for :title, @record.name %> + +
+

+ <%= link_to resource.friendly_name.pluralize, resource.index_path %> + / + <%= @record.name %> +

+
+ +
+
+
+ <%= @record.name.first(2).upcase %> +
+
+

<%= @record.name %>

+

<%= @record.slug %>

+

Created <%= @record.created_at.strftime("%-d %B %Y") %>

+
+
+ <% status = @record.subscription_status || "none" %> + <% badge_class = case status + when "active" then "bg-emerald-500/15 text-emerald-400" + when "trialing" then "bg-blue-500/15 text-blue-400" + when "past_due" then "bg-amber-500/15 text-amber-400" + when "canceled" then "bg-red-500/15 text-red-400" + else "bg-dark-700 text-dark-400" + end %> + + <%= status.titleize %> + + <% if @record.trialing? && @record.trial_days_remaining > 0 %> +
<%= @record.trial_days_remaining %>d left
+ <% end %> +
+
+
+ +<% members = @record.memberships %> +<% chats = @record.chats %> +<% total_cost = chats.sum(&:total_cost) %> +
+
+
Members
+
<%= members.size %>
+
+
+
Chats
+
<%= chats.size %>
+
+
+
Total Messages
+
<%= chats.sum(&:messages_count) %>
+
+
+
Total Cost
+
+ <% if total_cost.zero? %>$0.00<% elsif total_cost < 0.0001 %><$0.0001<% else %>$<%= '%.4f' % total_cost %><% end %> +
+
+
+ +
+
+

Details

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + <% if @record.current_period_ends_at.present? %> + + + + + <% end %> + + + + + + + + + +
ID<%= @record.id %>
Name<%= @record.name %>
Slug<%= @record.slug %>
API Key<%= @record.api_key.first(8) %>...<%= @record.api_key.last(4) %>
Stripe Customer<%= @record.stripe_customer_id || "-" %>
Subscription + + <%= status.titleize %> + + <% if @record.cancellation_pending? %> + Cancels at period end + <% end %> +
Period Ends<%= @record.current_period_ends_at.strftime("%-d %B %Y at %H:%M") %>
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
Updated<%= @record.updated_at.strftime("%-d %B %Y at %H:%M") %>
+
+
+ +
+
+

Members (<%= members.size %>)

+
+
+ + + + + + + + + + + <% members.sort_by { |m| -(Membership::ROLES.index(m.role) || -1) }.each do |membership| %> + + + + + + + <% end %> + +
UserRoleChatsJoined
+ <%= link_to "/madmin/users/#{membership.user.slug || membership.user.id}", class: "flex items-center gap-3" do %> + <% if membership.user.avatar_url.present? %> + + <% else %> +
+ <%= membership.user.display_name.first(2).upcase %> +
+ <% end %> +
+
<%= membership.user.display_name %>
+
@<%= membership.user.username %>
+
+ <% end %> +
+ <% role_class = case membership.role + when "owner" then "bg-amber-500/15 text-amber-400" + when "admin" then "bg-purple-500/15 text-purple-400" + else "bg-dark-600 text-dark-300" + end %> + + <%= membership.role.titleize %> + + <%= chats.count { |c| c.user_id == membership.user_id } %><%= membership.created_at.strftime("%-d %b %Y") %>
+
+
+ +<% if chats.any? %> +<% recent_chats = chats.sort_by(&:created_at).reverse.first(10) %> +
+
+

Recent Chats

+
+
+ + + + + + + + + + + + + <% recent_chats.each do |chat| %> + + + + + + + + + <% end %> + +
First MessageUserModelMessagesCostCreated
+ <%= link_to "/madmin/chats/#{chat.id}" do %> +
<%= chat.messages.min_by(&:created_at)&.content&.truncate(60) || "Empty chat" %>
+ <% end %> +
+ <% chat_user = members.find { |m| m.user_id == chat.user_id }&.user %> + <% if chat_user %> + <%= link_to "/madmin/users/#{chat_user.id}", class: "flex items-center gap-2" do %> + + <%= chat_user.name.presence || chat_user.email %> + <% end %> + <% else %> + Unknown + <% end %> + <%= chat.model&.name || "Default" %><%= chat.messages_count %> + <% if chat.total_cost.zero? %>-<% elsif chat.total_cost < 0.0001 %><$0.0001<% else %>$<%= '%.4f' % chat.total_cost %><% end %> + <%= chat.created_at.strftime("%-d %b %Y") %>
+
+ <% if chats.size > 10 %> +
+ <%= link_to "View all #{chats.size} chats →", "/madmin/chats?q=#{@record.slug}", class: "text-sm text-accent-400 hover:text-accent-300" %> +
+ <% end %> +
+<% end %> diff --git a/app/views/madmin/testimonials/index.html.erb b/app/views/madmin/testimonials/index.html.erb new file mode 100644 index 0000000..56319a0 --- /dev/null +++ b/app/views/madmin/testimonials/index.html.erb @@ -0,0 +1,95 @@ +<%= content_for :title, "Testimonials" %> + +
+

Testimonials

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search testimonials...", class: "form-input" %> +
+ +
+ <%= f.label :published, "Status", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.select :published, options_for_select([["All", ""], ["Published", "true"], ["Unpublished", "false"]], params[:published]), {}, class: "form-input" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? || params[:published].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + + + <% end %> + +
TestimonialUser<%= sortable :published, "Status" %><%= sortable :position, "Position" %>AI<%= sortable :created_at, "Date" %>
+ <%= link_to resource.show_path(record), class: "block" do %> + <% if record.heading.present? %> +
<%= record.heading %>
+
<%= record.quote&.truncate(60) %>
+ <% else %> +
<%= record.quote&.truncate(80) || "No quote" %>
+ <% end %> + <% end %> +
+ <%= link_to record.user.display_name, "/madmin/users/#{record.user.id}", class: "text-accent-400 hover:text-accent-300" %> + + <% if record.published? %> + Published + <% elsif record.reject_reason.present? %> + Rejected + <% else %> + Pending + <% end %> + + <% if record.position.present? %> + <%= record.position %> + <% else %> + - + <% end %> + + <% if record.ai_attempts > 0 %> + <%= record.ai_attempts %> attempt(s) + <% else %> + - + <% end %> + <%= record.created_at.strftime("%-d %b %Y") %>
+
+ + diff --git a/app/views/madmin/testimonials/show.html.erb b/app/views/madmin/testimonials/show.html.erb new file mode 100644 index 0000000..c151b78 --- /dev/null +++ b/app/views/madmin/testimonials/show.html.erb @@ -0,0 +1,98 @@ +<%= content_for :title, @record.heading.presence || "Testimonial" %> + +
+

+ <%= link_to "Testimonials", resource.index_path %> + / + <%= @record.heading.presence || "Testimonial" %> +

+
+ <%= link_to "Edit", resource.edit_path(@record), class: "btn btn-secondary" %> +
+
+ +<%# Testimonial preview card %> +
+
+
+ <% if @record.heading.present? %> +

<%= @record.heading %>

+ <% if @record.subheading.present? %> +

<%= @record.subheading %>

+ <% end %> + <% end %> +
+ <%= @record.quote %> +
+
+ <% if @record.published? %> + Published + <% elsif @record.reject_reason.present? %> + Rejected + <% else %> + Pending + <% end %> + + by <%= link_to @record.user.display_name, "/madmin/users/#{@record.user.id}", class: "text-accent-400 hover:text-accent-300" %> + +
+
+
+
+ +<%# AI-generated body text %> +<% if @record.body_text.present? %> +
+
+

AI-Generated Body

+
+
<%= @record.body_text %>
+
+<% end %> + +<%# AI feedback / rejection %> +<% if @record.ai_feedback.present? || @record.reject_reason.present? %> +
+
+

AI Processing

+
+
+ <% if @record.reject_reason.present? %> +
+
Rejection Reason
+
<%= @record.reject_reason %>
+
+ <% end %> + <% if @record.ai_feedback.present? %> +
+
AI Feedback
+
<%= @record.ai_feedback %>
+
+ <% end %> +
AI Attempts: <%= @record.ai_attempts %>
+
+
+<% end %> + +<%# Details %> +
+
+

Details

+
+
+ + + + + + + + + + + + + +
ID<%= @record.id %>
User<%= link_to @record.user.display_name, "/madmin/users/#{@record.user.id}", class: "text-accent-400 hover:text-accent-300" %>
Published<%= @record.published? ? "Yes" : "No" %>
Position<%= @record.position.presence || "-" %>
AI Attempts<%= @record.ai_attempts %>
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
Updated<%= @record.updated_at.strftime("%-d %B %Y at %H:%M") %>
+
+
diff --git a/app/views/madmin/tool_calls/index.html.erb b/app/views/madmin/tool_calls/index.html.erb new file mode 100644 index 0000000..3f3ea0e --- /dev/null +++ b/app/views/madmin/tool_calls/index.html.erb @@ -0,0 +1,73 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search tool calls...", class: "form-input" %> +
+ +
+ <%= f.label :created_at_from, "From:", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.date_field :created_at_from, value: params[:created_at_from], class: "form-input" %> +
+ +
+ <%= f.label :created_at_to, "To:", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.date_field :created_at_to, value: params[:created_at_to], class: "form-input" %> +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? || params[:created_at_from].present? || params[:created_at_to].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + <% end %> + +
<%= sortable :name, "Name" %><%= sortable :tool_call_id, "Tool Call ID" %><%= sortable :message_id, "Message" %><%= sortable :created_at, "Created" %>
+ <%= link_to resource.show_path(record), class: "font-medium hover:underline" do %> + <%= record.name %> + <% end %> + <%= record.tool_call_id.to_s.truncate(20) %> + <%= link_to "/madmin/messages/#{record.message_id}", class: "block truncate hover:underline" do %> + <%= record.message&.content&.truncate(50) || "Message ##{record.message_id.to_s[0..7]}" %> + <% end %> + <%= record.created_at.strftime("%-d %B %Y") %>
+
+ + diff --git a/app/views/madmin/users/index.html.erb b/app/views/madmin/users/index.html.erb new file mode 100644 index 0000000..eef9371 --- /dev/null +++ b/app/views/madmin/users/index.html.erb @@ -0,0 +1,129 @@ +<%= content_for :title, "Users" %> + +
+

Users

+
+ +
+ <%= form_with url: resource.index_path, method: :get, class: "flex gap-3 items-end flex-wrap" do |f| %> +
+ <%= f.label :q, "Search", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.search_field :q, value: params[:q], placeholder: "Search users...", class: "form-input" %> +
+ +
+ <%= f.label :role, "Role", class: "block text-sm font-medium text-dark-300 mb-1" %> + <%= f.select :role, options_for_select([["All roles", ""], ["Member", "0"], ["Admin", "1"]], params[:role]), {}, class: "form-input" %> +
+ +
+ +
+ +
+ <%= f.submit "Filter", class: "btn btn-primary" %> + <% if params[:q].present? || params[:role].present? || params[:trusted].present? %> + <%= link_to "Clear", resource.index_path, class: "btn btn-secondary" %> + <% end %> +
+ <% end %> +
+ +
+ + + + + + + + + + + + + + + <% @records.each do |record| %> + + + + + + + + + + <% end %> + +
<%= sortable :username, "User" %><%= sortable :role, "Role" %><%= sortable :company, "Company" %><%= sortable :published_posts_count, "Posts" %><%= sortable :published_comments_count, "Comments" %><%= sortable :github_stars_sum, "Stars" %><%= sortable :created_at, "Joined" %>
+ <%= link_to resource.show_path(record), class: "flex items-center gap-3" do %> + <% if record.avatar_url.present? %> + + <% else %> +
+ <%= record.display_name.first(2).upcase %> +
+ <% end %> +
+
<%= record.display_name %>
+
@<%= record.username %>
+
+ <% end %> +
+ <% if record.admin? %> + Admin + <% elsif record.trusted? %> + Trusted + <% else %> + Member + <% end %> + + <% if record.company.present? %> + <% team = @company_teams&.dig(record.company) %> + <% if team %> + <%= link_to "/madmin/teams/#{team.slug}", class: "text-accent-400 hover:text-accent-300 text-sm" do %> + <%= record.company %> + <% end %> + <% else %> + <%= record.company %> + <% end %> + <% else %> + - + <% end %> + + <% if record.published_posts_count > 0 %> + <%= record.published_posts_count %> + <% else %> + 0 + <% end %> + + <% if record.published_comments_count > 0 %> + <%= record.published_comments_count %> + <% else %> + 0 + <% end %> + + <% if record.github_stars_sum.to_i > 0 %> +
+ + <%= number_with_delimiter(record.github_stars_sum) %> +
+ <% else %> + 0 + <% end %> +
<%= record.created_at.strftime("%-d %b %Y") %>
+
+ + diff --git a/app/views/madmin/users/show.html.erb b/app/views/madmin/users/show.html.erb new file mode 100644 index 0000000..a2fbc24 --- /dev/null +++ b/app/views/madmin/users/show.html.erb @@ -0,0 +1,269 @@ +<%= content_for :title, @record.display_name %> + +
+

+ <%= link_to "Users", resource.index_path %> + / + <%= @record.display_name %> +

+
+ <%= link_to "Edit", resource.edit_path(@record), class: "btn btn-secondary" %> +
+
+ +<%# Profile card %> +
+
+ <% if @record.avatar_url.present? %> + + <% else %> +
+ <%= @record.display_name.first(2).upcase %> +
+ <% end %> +
+

<%= @record.display_name %>

+

@<%= @record.username %>

+
+ <% if @record.admin? %> + Admin + <% end %> + <% if @record.trusted? %> + Trusted User + <% end %> + <% if @record.open_to_work? %> + Open to Work + <% end %> + <% if @record.location.present? %> + <%= @record.location %> + <% end %> + Joined <%= @record.created_at.strftime("%-d %B %Y") %> +
+
+ + <%= inline_svg "icons/github.svg", class: "w-5 h-5" %> + GitHub + +
+
+ +<%# Stats %> +
+
+
Posts
+
<%= @record.published_posts_count %>
+
+
+
Comments
+
<%= @record.published_comments_count %>
+
+
+
Projects
+
<%= @record.projects.visible.size %>
+
+
+
Total Stars
+
<%= number_with_delimiter(@record.github_stars_sum.to_i) %>
+
+
+
Stars Gained
+
+<%= @record.stars_gained %>
+
+
+ +<%# Details %> +
+
+

Details

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID<%= @record.id %>
Username@<%= @record.username %>
Name<%= @record.name.presence || "-" %>
Email + <% if @record.email.present? %> + <%= @record.email %> + <% else %> + - + <% end %> +
Role<%= @record.role.titleize %>
Location<%= @record.location.presence || "-" %>
Normalized Location<%= @record.normalized_location.presence || "-" %>
Company + <% if @record.company.present? %> + <% company_team = Team.find_by(name: @record.company) %> + <% if company_team %> + <%= link_to @record.company, "/madmin/teams/#{company_team.slug}", class: "text-accent-400 hover:text-accent-300" %> + <% else %> + <%= @record.company %> + <% end %> + <% else %> + - + <% end %> +
Website + <% if @record.website.present? %> + " target="_blank" rel="noopener" class="text-accent-400 hover:text-accent-300"><%= @record.website %> + <% else %> + - + <% end %> +
Twitter + <% if @record.twitter.present? %> + @<%= @record.twitter %> + <% else %> + - + <% end %> +
Profile Public<%= @record.public? ? "Yes" : "No" %>
Newsletter<%= @record.unsubscribed_from_newsletter? ? "Unsubscribed" : "Subscribed" %>
Created<%= @record.created_at.strftime("%-d %B %Y at %H:%M") %>
Updated<%= @record.updated_at.strftime("%-d %B %Y at %H:%M") %>
+
+
+ +<%# Bio %> +<% if @record.bio.present? %> +
+
+

Bio

+
+
<%= @record.bio %>
+
+<% end %> + +<%# Testimonial %> +<% if @record.testimonial.present? %> +
+
+

Testimonial

+ <%= link_to "View", "/madmin/testimonials/#{@record.testimonial.id}", class: "text-sm text-accent-400 hover:text-accent-300" %> +
+
+ <% if @record.testimonial.heading.present? %> +
<%= @record.testimonial.heading %>
+ <% end %> +
<%= @record.testimonial.quote %>
+
+ <% if @record.testimonial.published? %> + Published + <% else %> + Unpublished + <% end %> +
+
+
+<% end %> + +<%# Recent Posts %> +<% recent_posts = @record.posts.includes(:category).order(created_at: :desc).limit(5) %> +<% if recent_posts.any? %> +
+
+

Recent Posts (<%= @record.posts.size %>)

+
+
+ + + + + + + + + + + <% recent_posts.each do |post| %> + + + + + + + <% end %> + +
TitleTypeStatusDate
+ <%= link_to post.title.truncate(60), "/madmin/posts/#{post.id}", class: "truncate block text-accent-400 hover:text-accent-300" %> + + <% type_class = case post.post_type + when "article" then "bg-blue-500/15 text-blue-400" + when "link" then "bg-emerald-500/15 text-emerald-400" + when "success_story" then "bg-amber-500/15 text-amber-400" + end %> + <%= post.post_type.titleize %> + + <% if post.published? %> + Published + <% else %> + Draft + <% end %> + <%= post.created_at.strftime("%-d %b %Y") %>
+
+ <% if @record.posts.size > 5 %> +
+ <%= link_to "View all posts", "/madmin/posts?q=#{@record.username}", class: "text-sm text-accent-400 hover:text-accent-300" %> +
+ <% end %> +
+<% end %> + +<%# Projects %> +<% projects = @record.projects.by_stars.limit(5) %> +<% if projects.any? %> +
+
+

Top Projects (<%= @record.projects.size %>)

+
+
+ + + + + + + + + + <% projects.each do |project| %> + + + + + + <% end %> + +
ProjectStarsStatus
+ <%= link_to project.name, "/madmin/projects/#{project.id}", class: "font-medium text-accent-400 hover:text-accent-300" %> + +
+ + <%= number_with_delimiter(project.stars) %> +
+
+ <% if project.archived? %> + Archived + <% elsif project.hidden? %> + Hidden + <% else %> + Visible + <% end %> +
+
+
+<% end %> diff --git a/app/views/messages/_content.html.erb b/app/views/messages/_content.html.erb new file mode 100644 index 0000000..70d1174 --- /dev/null +++ b/app/views/messages/_content.html.erb @@ -0,0 +1 @@ +<%= content %> \ No newline at end of file diff --git a/app/views/messages/_form.html.erb b/app/views/messages/_form.html.erb new file mode 100644 index 0000000..72cab3e --- /dev/null +++ b/app/views/messages/_form.html.erb @@ -0,0 +1,41 @@ +<%= form_with(model: message, url: team_chat_messages_path(current_team, @chat), id: "new_message", data: { controller: "chat-input", chat_input_target: "form" }) do |form| %> + <% if message.errors.any? %> +
+
    + <% message.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+
+ <%= form.text_area :content, + rows: 1, + class: "w-full resize-none border-0 bg-transparent focus:ring-0 focus:outline-none focus:border-0 focus:shadow-none placeholder-dark-400 text-dark-100", + placeholder: t("messages.form.placeholder"), + autofocus: true, + style: "box-shadow: none !important;", + data: { + chat_input_target: "textarea", + action: "keydown->chat-input#submit input->chat-input#resize" + } %> +
+ +
+
+ + <%= form.file_field :attachments, multiple: true, class: "hidden", data: { chat_input_target: "fileInput", action: "change->chat-input#handleFileSelect" }, accept: "image/png,image/jpeg,image/gif,image/webp,.pdf" %> +
+
+ <%= @chat.model&.name || t("common.default") %> + <%= form.button type: "submit", class: "bg-dark-600 hover:bg-dark-500 text-white rounded-lg p-2 transition-colors cursor-pointer" do %> + <%= inline_svg "icons/arrow-up.svg", class: "w-4 h-4" %> + <% end %> +
+
+
+<% end %> diff --git a/app/views/messages/_message.html.erb b/app/views/messages/_message.html.erb new file mode 100644 index 0000000..badc66f --- /dev/null +++ b/app/views/messages/_message.html.erb @@ -0,0 +1,52 @@ +
+ <% if message.role == 'user' %> +
+
+
+ <% if message.attachments.any? %> +
+ <% message.attachments.each do |attachment| %> + <% if attachment.content_type.start_with?("image/") && attachment.variable? %> + + <% elsif attachment.content_type.start_with?("image/") %> + + <% else %> + + <% end %> + <% end %> +
+ <% end %> +
<%= message.content %>
+
+
<%= time_ago_in_words(message.created_at) %> ago
+
+
+ <% else %> +
+
+ <% if message.content.blank? %> +
+ + + +
+ <% else %> + <%= markdown(message.content) %> + <% end %> +
+ <% if message.tool_call? %> + <%= render "messages/tool_calls", message: message %> + <% end %> + <% unless message.content.blank? %> +
<%= time_ago_in_words(message.created_at) %> ago
+ <% end %> +
+ <% end %> +
diff --git a/app/views/messages/_tool_calls.html.erb b/app/views/messages/_tool_calls.html.erb new file mode 100644 index 0000000..9d79b0c --- /dev/null +++ b/app/views/messages/_tool_calls.html.erb @@ -0,0 +1,7 @@ +
+ <% message.tool_calls.each do |tool_call| %> +
+ <%= tool_call.name %>(<%= tool_call.arguments.map { |k, v| "#{k}: #{v.inspect}" }.join(", ") %>) +
+ <% end %> +
diff --git a/app/views/messages/create.turbo_stream.erb b/app/views/messages/create.turbo_stream.erb new file mode 100644 index 0000000..374f272 --- /dev/null +++ b/app/views/messages/create.turbo_stream.erb @@ -0,0 +1,3 @@ +<%= turbo_stream.replace "new_message" do %> + <%= render "messages/form", chat: @chat, message: @chat.messages.build %> +<% end %> \ No newline at end of file diff --git a/app/views/models/_model.html.erb b/app/views/models/_model.html.erb new file mode 100644 index 0000000..726a3e3 --- /dev/null +++ b/app/views/models/_model.html.erb @@ -0,0 +1,16 @@ + + <%= model.provider %> + <%= model.name %> + <%= number_with_delimiter(model.context_window) if model.context_window %> + + <% if model.pricing && model.pricing['text_tokens'] && model.pricing['text_tokens']['standard'] %> + <% input = model.pricing['text_tokens']['standard']['input_per_million'] %> + <% output = model.pricing['text_tokens']['standard']['output_per_million'] %> + <% if input && output %> + $<%= "%.2f" % input %> / $<%= "%.2f" % output %> + <% end %> + <% end %> + + <%= link_to t("models.model.show"), team_model_path(current_team, model) %> + <%= link_to t("models.model.start_chat"), new_team_chat_path(current_team, model: model.model_id) %> + diff --git a/app/views/models/index.html.erb b/app/views/models/index.html.erb new file mode 100644 index 0000000..8ea806c --- /dev/null +++ b/app/views/models/index.html.erb @@ -0,0 +1,30 @@ +

<%= notice %>

+ +<% content_for :title, t(".title") %> + +

<%= t(".heading") %>

+ +<% if current_admin %> +

+ <%= button_to t(".refresh"), team_models_refresh_path(current_team), method: :post %> +

+<% end %> + +
+ + + + + + + + + + + + <%= render @models %> + +
<%= t(".table.provider") %><%= t(".table.model") %><%= t(".table.context_window") %><%= t(".table.pricing") %>
+
+ +<%= link_to t(".back"), team_chats_path(current_team) %> diff --git a/app/views/models/show.html.erb b/app/views/models/show.html.erb new file mode 100644 index 0000000..5cc4e8d --- /dev/null +++ b/app/views/models/show.html.erb @@ -0,0 +1,18 @@ +<% content_for :title, @model.name %> + +

<%= @model.name %>

+ +

<%= t(".id") %>: <%= @model.model_id %>

+

<%= t(".provider") %>: <%= @model.provider %>

+

<%= t(".context_window") %>: <%= t(".context_window_value", count: number_with_delimiter(@model.context_window)) %>

+

<%= t(".max_output") %>: <%= t(".max_output_value", count: number_with_delimiter(@model.max_output_tokens)) %>

+ +<% if @model.capabilities.any? %> +

<%= t(".capabilities") %>: <%= @model.capabilities.join(", ") %>

+<% end %> + +

+ <%= link_to t(".start_chat"), new_team_chat_path(current_team, model: @model.model_id) %> | + <%= link_to t(".all_models"), team_models_path(current_team) %> | + <%= link_to t(".back"), team_chats_path(current_team) %> +

diff --git a/app/views/og_images/show.html.erb b/app/views/og_images/show.html.erb new file mode 100644 index 0000000..3734b0c --- /dev/null +++ b/app/views/og_images/show.html.erb @@ -0,0 +1,110 @@ + + + + + + OG Image Preview + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + + + +
+ <%# Grid pattern overlay %> +
+ + <%# Decorative gradient orbs %> +
+
+ + <%# Content container %> +
+ <%# Logo/Icon %> +
+ <%= inline_svg "icons/lightning.svg", class: "w-14 h-14 text-white" %> +
+ + <%# App name %> +

+ <%= @app_name %> +

+ + <%# Tagline %> +

+ <%= @tagline %> +

+ + <%# Tech stack badges %> +
+ + Rails 8 + + + Hotwire + + + SQLite + + + AI Chat + +
+
+ + <%# Domain at bottom %> +
+ + <%= @domain %> + +
+
+ + diff --git a/app/views/onboardings/show.html.erb b/app/views/onboardings/show.html.erb new file mode 100644 index 0000000..cd26741 --- /dev/null +++ b/app/views/onboardings/show.html.erb @@ -0,0 +1,56 @@ +<% content_for :title, t(".title") %> + +
+
+
+ <%= inline_svg "icons/lightning.svg", class: "w-8 h-8 text-white" %> +
+

<%= t(".heading") %>

+

<%= t(".subheading") %>

+
+ +
+ <%= form_with scope: :onboarding, url: onboarding_path, method: :patch, class: "space-y-6" do |form| %> + <% if @user.errors.any? || @team&.errors&.any? %> +
+
    + <% @user.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> + <% @team&.errors&.full_messages&.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :name, t(".name_label"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :name, + value: @user.name, + required: true, + autofocus: true, + autocomplete: "name", + placeholder: t(".name_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500" %> +
+ + <% if @team && @user.owner_of?(@team) %> +
+ <%= form.label :team_name, t(".team_name_label"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :team_name, + value: @team.name, + required: true, + placeholder: t(".team_name_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500" %> +

<%= t(".team_name_help") %>

+
+ <% end %> + +
+ <%= form.submit t(".submit"), + class: "w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-4 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-blue-500/25" %> +
+ <% end %> +
+
diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb new file mode 100644 index 0000000..0233842 --- /dev/null +++ b/app/views/profiles/edit.html.erb @@ -0,0 +1,63 @@ +<% content_for :title, t(".heading") %> + +
+
+

<%= t(".heading") %>

+
+ +
+ <%= form_with model: @user, url: team_profile_path(current_team), method: :patch, class: "space-y-6" do |form| %> + <% if @user.errors.any? %> +
+
    + <% @user.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= t(".avatar_label") %> +
+ <%= render "shared/image_upload", + form: form, + field: :avatar, + attached: @user.avatar.attached?, + image_url: (@user.avatar.attached? ? url_for(@user.avatar) : nil), + remove_field: :remove_avatar, + aspect_class: "aspect-square rounded-full" %> +
+
+ +
+ <%= form.label :name, t(".name_label"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :name, + required: true, + placeholder: t(".name_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500" %> +
+ +
+ <%= form.label :locale, t(".locale_label"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.select :locale, + options_for_select( + [[t(".locale_auto"), ""]] + @languages.map { |l| ["#{l.native_name} (#{l.name})", l.code] }, + @user.locale.to_s + ), + {}, + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all" %> +

<%= t(".locale_help") %>

+
+ +
+ <%= form.submit t(".submit"), + class: "w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-4 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-blue-500/25" %> +
+ <% end %> +
+ +
+ <%= link_to t("common.back", default: "← Back"), team_profile_path(current_team), class: "text-dark-400 hover:text-dark-300 transition-colors" %> +
+
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb new file mode 100644 index 0000000..5561f05 --- /dev/null +++ b/app/views/profiles/show.html.erb @@ -0,0 +1,43 @@ +<% content_for :title, t(".title") %> + +
+
+

<%= t(".heading") %>

+ <%= link_to t(".edit_button"), edit_team_profile_path(current_team), class: "inline-flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-dark-200 rounded-lg transition-colors" %> +
+ +
+
+ <% if @user.avatar.attached? %> + <%= image_tag url_for(@user.avatar), class: "w-16 h-16 rounded-full object-cover flex-shrink-0" %> + <% else %> +
+ <%= @user.name&.first&.upcase || "?" %> +
+ <% end %> +
+

<%= @user.name %>

+

<%= @user.email %>

+
+
+ +
+
+
<%= t(".name") %>
+
<%= @user.name %>
+
+
+
<%= t(".email") %>
+
<%= @user.email %>
+
+
+
<%= t(".language") %>
+
<%= @user.locale.present? ? @user_language&.native_name : t(".locale_auto") %>
+
+
+
<%= t(".member_since") %>
+
<%= @user.created_at.strftime("%B %d, %Y") %>
+
+
+
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..b90d4c2 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,31 @@ +
+
+
+ <%= inline_svg "icons/lightning.svg", class: "w-8 h-8 text-white" %> +
+

<%= t(".heading") %>

+

<%= t(".subheading") %>

+
+ +
+ <%= form_with scope: :session, url: session_path, method: :post, class: "space-y-6" do |form| %> +
+ <%= form.label :email, t(".email_label"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.email_field :email, + required: true, + autofocus: true, + autocomplete: "email", + placeholder: t(".email_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500" %> +

+ <%= t(".email_help") %> +

+
+ +
+ <%= form.submit t(".submit"), + class: "w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-4 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-blue-500/25" %> +
+ <% end %> +
+
diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb new file mode 100644 index 0000000..c02e3ba --- /dev/null +++ b/app/views/shared/_flash.html.erb @@ -0,0 +1,16 @@ +<% if flash.any? %> +
+ <% flash.each do |type, message| %> +
+ <%= message %> + +
+ <% end %> +
+<% end %> diff --git a/app/views/shared/_image_upload.html.erb b/app/views/shared/_image_upload.html.erb new file mode 100644 index 0000000..71f4d46 --- /dev/null +++ b/app/views/shared/_image_upload.html.erb @@ -0,0 +1,59 @@ +<%# + Shared image upload with drag-and-drop, preview, and removal. + Uses the image-upload Stimulus controller. + + Required params: + form: Form builder instance + field: Symbol for the file field (e.g. :image, :cover_image) + attached: Boolean — whether a file is currently attached + image_url: URL of the current image (when attached), or nil + + Optional params: + label: Label text (default: none, caller renders own label) + multiple: Allow multiple files (default: false) + remove_field: Symbol for the hidden remove flag field (default: nil — no remove support) + aspect_class: CSS class for aspect ratio (default: "aspect-[3/2]") + hint: Hint text shown below (default: nil) +%> +<% + label_text = local_assigns[:label] + multiple = local_assigns[:multiple] || false + remove_field = local_assigns[:remove_field] + aspect_class = local_assigns[:aspect_class] || "aspect-[3/2]" + hint_text = local_assigns[:hint] +%> + +
+ <% if label_text %> + <%= label_text %> + <% end %> + +
+
+ <%= inline_svg "icons/image.svg", class: "w-5 h-5 text-dark-500 mx-auto mb-1" %> + <%= t("shared.image_upload.drop_image") %> +
+
+ src="<%= image_url %>"<% end %>> + +
+ +
+ + <%= form.file_field field, accept: "image/*", multiple: multiple, data: { image_upload_target: "input" }, class: "hidden" %> + <% if remove_field %> + <%= form.hidden_field remove_field, value: "0", data: { image_upload_target: "removeFlag" } %> + <% end %> + <% if hint_text %> +

<%= hint_text %>

+ <% end %> +
diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb new file mode 100644 index 0000000..0158d3a --- /dev/null +++ b/app/views/shared/_sidebar.html.erb @@ -0,0 +1,92 @@ +<%# Modern dark sidebar navigation - inspired by Anthropic/OpenAI/Lovable %> + +<%# Sidebar %> + + +<%# Mobile overlay %> + diff --git a/app/views/shared/_sidebar_group.html.erb b/app/views/shared/_sidebar_group.html.erb new file mode 100644 index 0000000..2d8a8b1 --- /dev/null +++ b/app/views/shared/_sidebar_group.html.erb @@ -0,0 +1,45 @@ +<%# + Collapsible sidebar navigation group + Params: + id: Unique group identifier for collapse state + icon: Icon name (without .svg extension) + label: Group header text + path: Path used for active state highlighting (optional) + Block: Content (nested links) to show when expanded +%> +<% + group_path = local_assigns[:path] + # Check if this group is active (exact match only, not child pages) + is_active = group_path && current_page?(group_path) + + # Use same base classes as sidebar_link for consistency + base_classes = "flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-all duration-200 hover:bg-white/5" + active_classes = "text-white" + inactive_classes = "text-dark-400 hover:text-dark-100" +%> + +
+ <%# Group header - entire row toggles the group %> + + + <%# Collapsible content %> + +
diff --git a/app/views/shared/_sidebar_link.html.erb b/app/views/shared/_sidebar_link.html.erb new file mode 100644 index 0000000..662b237 --- /dev/null +++ b/app/views/shared/_sidebar_link.html.erb @@ -0,0 +1,38 @@ +<%# + Sidebar navigation link + Params: + path: Route path (string or path helper) + icon: Icon name (without .svg extension) + label: Link text + nested: Boolean, if true applies nested styling (optional) + truncate: Boolean, if true truncates with CSS ellipsis (optional) + image: Active Storage attachment or image URL to show instead of icon (optional) + count: Integer, if present shows a counter badge on the right (optional) +%> +<% + path_str = path.to_s.split('?').first + # Check if this is a root path (exact match for "/" or "/madmin") + is_root = path_str == "/" || path_str == "/madmin" + # Active if current page or if path matches start of request path (for non-root paths) + is_active = current_page?(path) || (!is_root && request.path.start_with?(path_str) && path_str.length > 1) + nested = local_assigns[:nested] || false + should_truncate = local_assigns[:truncate] || false + + base_classes = "flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-all duration-200 hover:bg-white/5" + active_classes = "text-white" + inactive_classes = "text-dark-400 hover:text-dark-100" + nested_classes = nested ? "pl-10 sidebar-nested-link" : "" + truncate_classes = should_truncate ? "truncate" : "" +%> + +<%= link_to path, class: "#{base_classes} #{is_active ? active_classes : inactive_classes} #{nested_classes}", title: label do %> + <% if local_assigns[:image] %> + <%= image_tag image, class: "w-5 h-5 flex-shrink-0 rounded-full object-cover" %> + <% else %> + <%= inline_svg "icons/#{icon}.svg", class: "w-5 h-5 flex-shrink-0" %> + <% end %> + <%= label %> + <% if local_assigns[:count] && count.to_i > 0 %> + <%= number_with_delimiter(count) %> + <% end %> +<% end %> diff --git a/app/views/teams/billing/show.html.erb b/app/views/teams/billing/show.html.erb new file mode 100644 index 0000000..76abe8b --- /dev/null +++ b/app/views/teams/billing/show.html.erb @@ -0,0 +1,64 @@ +<% content_for :title, t(".heading") %> + +
+

<%= t(".heading") %>

+ +
+
+
+
<%= t(".status_label") %>
+
+ <% if current_team.trialing? %> + <%= t(".status.trialing") %> + <% elsif current_team.cancellation_pending? %> + <%= t(".status.canceling") %> + <% elsif current_team.subscribed? %> + <%= t(".status.active") %> + <% elsif current_team.past_due? %> + <%= t(".status.past_due") %> + <% else %> + <%= t(".status.inactive") %> + <% end %> +
+
+ + <% if current_team.trialing? %> +
+
<%= t(".trial_ends_label") %>
+
+ <%= l(current_team.current_period_ends_at, format: :long) %> + (<%= t(".days_remaining", count: current_team.trial_days_remaining) %>) +
+
+ <% elsif current_team.cancellation_pending? %> +
+
<%= t(".cancellation_notice_label") %>
+
+ <%= t(".cancellation_notice", date: l(current_team.current_period_ends_at, format: :long)) %> +
+
+ <% elsif current_team.current_period_ends_at.present? %> +
+
<%= t(".period_ends_label") %>
+
<%= l(current_team.current_period_ends_at, format: :long) %>
+
+ <% end %> +
+
+ +
+ <% if @portal_url %> + <%= link_to t(".manage_in_stripe"), @portal_url, class: "inline-flex items-center gap-2 px-6 py-3 bg-accent-600 hover:bg-accent-500 text-white rounded-lg transition-colors", target: "_blank", rel: "noopener" %> + <% end %> + + <% if current_team.cancellation_pending? %> + <%= button_to t(".resume_subscription"), team_subscription_cancellation_path(current_team), method: :delete, class: "inline-flex items-center gap-2 px-6 py-3 bg-green-700 hover:bg-green-600 text-white rounded-lg transition-colors" %> + <% elsif current_team.subscribed? && !current_team.trialing? %> + <%= button_to t(".cancel_subscription"), team_subscription_cancellation_path(current_team), method: :post, class: "inline-flex items-center gap-2 px-6 py-3 bg-dark-700 hover:bg-dark-600 text-dark-200 rounded-lg transition-colors", data: { turbo_confirm: t(".cancel_confirm") } %> + <% end %> + + <% unless current_team.subscribed? && !current_team.trialing? %> + <%= link_to t(".view_plans"), team_pricing_path(current_team), class: "inline-flex items-center gap-2 px-6 py-3 bg-dark-700 hover:bg-dark-600 text-dark-200 rounded-lg transition-colors" %> + <% end %> +
+
diff --git a/app/views/teams/index.html.erb b/app/views/teams/index.html.erb new file mode 100644 index 0000000..9d20698 --- /dev/null +++ b/app/views/teams/index.html.erb @@ -0,0 +1,31 @@ +<% content_for :title, t(".heading") %> + +
+
+
+ <%= inline_svg "icons/users.svg", class: "w-8 h-8 text-white" %> +
+

<%= t(".heading") %>

+
+ +
+ <% @teams.each do |team| %> + <%= link_to team_root_path(team), class: "block card-bg p-4 hover:border-blue-500/50 transition-all" do %> +
+
+ <%= inline_svg "icons/users.svg", class: "w-5 h-5 text-blue-400" %> +
+
+

<%= team.name %>

+

/t/<%= team.slug %>

+
+ <%= inline_svg "icons/chevron-right.svg", class: "w-5 h-5 text-dark-400" %> +
+ <% end %> + <% end %> +
+ +
+ <%= link_to t(".create_team"), new_team_path, class: "inline-flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors" %> +
+
diff --git a/app/views/teams/languages/index.html.erb b/app/views/teams/languages/index.html.erb new file mode 100644 index 0000000..4655c04 --- /dev/null +++ b/app/views/teams/languages/index.html.erb @@ -0,0 +1,54 @@ +<% content_for :title, t(".heading") %> + +
+
+

<%= t(".heading") %>

+
+ + <%# Active languages %> +
+

<%= t(".active_heading") %>

+

<%= t(".active_description") %>

+ +
+ <% @active_languages.each do |language| %> +
+
+ <%= language.localized_name %> + <% if language.code != I18n.locale.to_s %> + <%= language.native_name %> + <% end %> +
+ <% if @active_languages.size <= 1 %> + <%= t(".required") %> + <% else %> + <%= button_to team_language_path(current_team, language), + method: :delete, + class: "text-sm text-dark-400 hover:text-red-400 transition-colors", + data: { turbo_confirm: t(".remove_confirm", language: language.localized_name) } do %> + <%= inline_svg "icons/trash.svg", class: "w-4 h-4" %> + <% end %> + <% end %> +
+ <% end %> +
+
+ + <%# Add language %> + <% if @available_languages.any? %> +
+

<%= t(".add_heading") %>

+

<%= t(".add_description") %>

+ + <%= form_with url: team_languages_path(current_team), method: :post, class: "flex items-center gap-4" do |f| %> +
+ <%= f.select :language_id, + @available_languages.map { |l| ["#{l.localized_name} (#{l.native_name})", l.id] }, + { prompt: t(".select_language") }, + class: "w-full px-4 py-2 bg-dark-800 border border-dark-600 rounded-lg text-dark-100 focus:ring-2 focus:ring-accent-500 focus:border-accent-500" %> +
+ <%= f.submit t(".add_button"), class: "px-4 py-2 bg-accent-600 hover:bg-accent-500 text-white rounded-lg transition-colors text-sm cursor-pointer" %> + <% end %> +
+ <% end %> +
diff --git a/app/views/teams/members/index.html.erb b/app/views/teams/members/index.html.erb new file mode 100644 index 0000000..eb3e9df --- /dev/null +++ b/app/views/teams/members/index.html.erb @@ -0,0 +1,52 @@ +<% content_for :title, t(".heading") %> + +
+
+

<%= t(".heading") %>

+ <% if current_membership&.admin? %> + <%= link_to t(".invite_button"), new_team_member_path(current_team), class: "inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors" %> + <% end %> +
+ +
+ + + + + + + <% if current_membership&.admin? %> + + <% end %> + + + + <% @memberships.each do |membership| %> + + + + + <% if current_membership&.admin? %> + + <% end %> + + <% end %> + +
Name<%= t(".role") %><%= t(".invited_by") %>
+ <%= link_to team_member_path(current_team, membership), class: "block group" do %> +
<%= membership.user.name || membership.user.email %>
+
<%= membership.user.email %>
+ <% end %> +
+ + <%= t(".#{membership.role}") %> + + + <%= membership.invited_by&.name || "—" %> + + <% unless membership.user == current_user || (membership.owner? && current_team.memberships.where(role: "owner").count == 1) %> + <%= button_to t(".remove"), team_member_path(current_team, membership), method: :delete, class: "text-sm text-red-400 hover:text-red-300", data: { confirm: "Are you sure?" } %> + <% end %> +
+
+
diff --git a/app/views/teams/members/new.html.erb b/app/views/teams/members/new.html.erb new file mode 100644 index 0000000..8d78973 --- /dev/null +++ b/app/views/teams/members/new.html.erb @@ -0,0 +1,34 @@ +<% content_for :title, t(".heading") %> + +
+
+
+ <%= inline_svg "icons/users.svg", class: "w-8 h-8 text-white" %> +
+

<%= t(".heading") %>

+

<%= t(".description") %>

+
+ +
+ <%= form_with url: team_members_path(current_team), method: :post, class: "space-y-6" do |form| %> +
+ <%= form.label :email, t(".email_label"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.email_field :email, + required: true, + autofocus: true, + value: @invite_email, + placeholder: t(".email_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500" %> +
+ +
+ <%= form.submit t(".submit"), + class: "w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-4 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-blue-500/25" %> +
+ <% end %> +
+ +
+ <%= link_to "← Back to members", team_members_path(current_team), class: "text-dark-400 hover:text-dark-300 transition-colors" %> +
+
diff --git a/app/views/teams/members/show.html.erb b/app/views/teams/members/show.html.erb new file mode 100644 index 0000000..0ee6913 --- /dev/null +++ b/app/views/teams/members/show.html.erb @@ -0,0 +1,47 @@ +<% content_for :title, t(".title", name: @membership.user.name || @membership.user.email) %> + +
+
+ <%= link_to t(".back"), team_members_path(current_team), class: "text-dark-400 hover:text-dark-300 transition-colors text-sm" %> +
+ +
+
+
+ <%= @membership.user.name&.first&.upcase || "?" %> +
+
+

<%= @membership.user.name || @membership.user.email %>

+ + <%= t(".#{@membership.role}", + default: @membership.role.capitalize) %> + +
+
+ +
+
+
<%= t(".name") %>
+
<%= @membership.user.name || "—" %>
+
+
+
<%= t(".email") %>
+
<%= @membership.user.email %>
+
+
+
<%= t(".role") %>
+
<%= @membership.role.capitalize %>
+
+
+
<%= t(".joined") %>
+
<%= @membership.created_at.strftime("%B %d, %Y") %>
+
+ <% if @membership.invited_by %> +
+
<%= t(".invited_by") %>
+
<%= @membership.invited_by.name || @membership.invited_by.email %>
+
+ <% end %> +
+
+
diff --git a/app/views/teams/new.html.erb b/app/views/teams/new.html.erb new file mode 100644 index 0000000..f5cbc60 --- /dev/null +++ b/app/views/teams/new.html.erb @@ -0,0 +1,42 @@ +<% content_for :title, t(".heading") %> + +
+
+
+ <%= inline_svg "icons/users.svg", class: "w-8 h-8 text-white" %> +
+

<%= t(".heading") %>

+
+ +
+ <%= form_with model: @team, url: teams_path, class: "space-y-6" do |form| %> + <% if @team.errors.any? %> +
+
    + <% @team.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :name, t(".name_label"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :name, + required: true, + autofocus: true, + placeholder: t(".name_placeholder"), + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500" %> +
+ +
+ <%= form.submit t(".submit"), + class: "w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-4 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-blue-500/25" %> +
+ <% end %> +
+ +
+ <%= link_to "← Back to teams", teams_path, class: "text-dark-400 hover:text-dark-300 transition-colors" %> +
+
diff --git a/app/views/teams/pricing/show.html.erb b/app/views/teams/pricing/show.html.erb new file mode 100644 index 0000000..821d24c --- /dev/null +++ b/app/views/teams/pricing/show.html.erb @@ -0,0 +1,78 @@ +<% content_for :title, t(".heading") %> + +
+
+

<%= t(".heading") %>

+

<%= t(".description") %>

+
+ + <% if current_team.trialing? %> +
+

+ <%= t(".trial_active", count: current_team.trial_days_remaining) %> +

+
+ <% end %> + + <% if current_team.subscribed? && !current_team.trialing? %> +
+

<%= t(".already_subscribed") %>

+ <%= link_to t(".manage_billing"), team_billing_path(current_team), class: "inline-flex items-center gap-2 px-6 py-3 bg-accent-600 hover:bg-accent-500 text-white rounded-lg transition-colors" %> +
+ <% else %> +
+ <% if @yearly_prices.any? && @monthly_prices.any? %> +
+
+ + +
+
+ <% end %> + +
+
+ <% @monthly_prices.each do |price| %> +
+

<%= price.product_name %>

+
+ <%= price.formatted_amount %> + / <%= t(".month") %> +
+
+ <%= button_to t(".subscribe"), team_checkout_path(current_team), params: { price_id: price.id }, data: { turbo: false }, class: "w-full px-6 py-3 bg-accent-600 hover:bg-accent-500 text-white rounded-lg transition-colors text-center font-medium" %> +
+
+ <% end %> +
+
+ + +
+ <% end %> +
diff --git a/app/views/teams/settings/edit.html.erb b/app/views/teams/settings/edit.html.erb new file mode 100644 index 0000000..7f935b6 --- /dev/null +++ b/app/views/teams/settings/edit.html.erb @@ -0,0 +1,66 @@ +<% content_for :title, t(".heading") %> + +
+
+

<%= t(".heading") %>

+
+ + <% team_form = @team_form || current_team %> +
+ <%= form_with model: team_form, url: team_settings_path(current_team), method: :patch, class: "space-y-6", + data: { controller: "name-check", name_check_url_value: team_name_check_path(current_team), name_check_original_value: current_team.name } do |form| %> + <% if team_form.errors.any? %> +
+
    + <% team_form.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= t(".logo_label") %> +
+ <%= render "shared/image_upload", + form: form, + field: :logo, + attached: team_form.logo.attached?, + image_url: (url_for(team_form.logo) if team_form.logo.attached?), + remove_field: :remove_logo, + aspect_class: "aspect-square" %> +
+
+ +
+ <%= form.label :name, t(".name_label"), class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.text_field :name, + required: true, + autofocus: true, + placeholder: t(".name_placeholder"), + data: { name_check_target: "input", action: "input->name-check#check" }, + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500" %> +
+ + +
+
+ +
+ <%= form.submit t(".submit"), + data: { name_check_target: "submit" }, + class: "w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-4 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-blue-500/25 disabled:opacity-50 disabled:cursor-not-allowed" %> +
+ <% end %> +
+ +
+ <%= link_to t(".back"), team_settings_path(current_team), class: "text-dark-400 hover:text-dark-300 transition-colors" %> +
+
diff --git a/app/views/teams/settings/show.html.erb b/app/views/teams/settings/show.html.erb new file mode 100644 index 0000000..3f991ed --- /dev/null +++ b/app/views/teams/settings/show.html.erb @@ -0,0 +1,44 @@ +<% content_for :title, t(".heading") %> + +
+
+

<%= t(".heading") %>

+ <%= link_to t(".edit_button"), edit_team_settings_path(current_team), class: "inline-flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-dark-200 rounded-lg transition-colors" %> +
+ +
+
+
+
<%= t(".logo_label") %>
+
+ <% if current_team.logo.attached? %> + <%= image_tag url_for(current_team.logo), class: "w-20 h-20 rounded-lg object-cover" %> + <% else %> + <%= t(".no_logo") %> + <% end %> +
+
+
+
<%= t(".name_label") %>
+
<%= current_team.name %>
+
+
+
<%= t(".slug_label") %>
+
/t/<%= current_team.slug %>
+
+
+
+ +
+

<%= t(".api_key_heading") %>

+

<%= t(".api_key_description") %>

+
+ <%= current_team.api_key %> + <%= button_to t(".regenerate_api_key"), + team_settings_api_key_regeneration_path(current_team), + method: :post, + class: "px-4 py-2 bg-accent-600 hover:bg-accent-500 text-white rounded-lg transition-colors text-sm", + data: { turbo_confirm: t(".regenerate_confirm") } %> +
+
+
diff --git a/app/views/user_mailer/magic_link.html.erb b/app/views/user_mailer/magic_link.html.erb new file mode 100644 index 0000000..2ba5d45 --- /dev/null +++ b/app/views/user_mailer/magic_link.html.erb @@ -0,0 +1,18 @@ +

<%= t(".heading") %>

+ +

<%= t(".greeting", name: @user.name) %>

+ +

<%= t(".instruction") %>

+ +

+ + <%= t(".button") %> + +

+ +

<%= t(".link_instruction") %>

+

<%= @magic_link_url %>

+ +

<%= t(".expiry") %>

+ +

<%= t(".ignore") %>

diff --git a/app/views/user_mailer/magic_link.text.erb b/app/views/user_mailer/magic_link.text.erb new file mode 100644 index 0000000..7946e36 --- /dev/null +++ b/app/views/user_mailer/magic_link.text.erb @@ -0,0 +1,9 @@ +<%= t(".greeting", name: @user.name) %> + +<%= t(".instruction") %> + +<%= @magic_link_url %> + +<%= t(".expiry") %> + +<%= t(".ignore") %> diff --git a/app/views/user_mailer/team_invitation.html.erb b/app/views/user_mailer/team_invitation.html.erb new file mode 100644 index 0000000..002eef2 --- /dev/null +++ b/app/views/user_mailer/team_invitation.html.erb @@ -0,0 +1,18 @@ +

<%= t(".heading", team_name: @team.name) %>

+ +

<%= t(".greeting", name: @user.name) %>

+ +

<%= t(".instruction", inviter_name: @invited_by.name, team_name: @team.name) %>

+ +

+ + <%= t(".button") %> + +

+ +

<%= t(".link_instruction") %>

+

<%= @invite_url %>

+ +

<%= t(".expiry") %>

+ +

<%= t(".ignore") %>

diff --git a/app/views/user_mailer/team_invitation.text.erb b/app/views/user_mailer/team_invitation.text.erb new file mode 100644 index 0000000..fec9383 --- /dev/null +++ b/app/views/user_mailer/team_invitation.text.erb @@ -0,0 +1,9 @@ +<%= t(".greeting", name: @user.name) %> + +<%= t(".instruction", inviter_name: @invited_by.name, team_name: @team.name) %> + +<%= @invite_url %> + +<%= t(".expiry") %> + +<%= t(".ignore") %> diff --git a/app/views/users/_profile_settings.html.erb b/app/views/users/_profile_settings.html.erb index 256e7e4..dfee2c9 100644 --- a/app/views/users/_profile_settings.html.erb +++ b/app/views/users/_profile_settings.html.erb @@ -2,8 +2,8 @@ <%# Public Profile Toggle %>
Public profile - <%= button_to toggle_public_user_settings_path, - method: :post, + <%= button_to user_settings_visibility_path, + method: :patch, class: "relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 #{user.public? ? 'bg-red-600' : 'bg-gray-400'}", data: { turbo_stream: true } do %> Toggle public profile @@ -15,8 +15,8 @@ <%# Newsletter Toggle %>
Receive news - <%= button_to toggle_newsletter_user_settings_path, - method: :post, + <%= button_to user_settings_newsletter_path, + method: :patch, class: "relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 #{user.unsubscribed_from_newsletter? ? 'bg-gray-400' : 'bg-red-600'}", data: { turbo_stream: true } do %> Toggle newsletter subscription @@ -28,8 +28,8 @@ <%# Open to Work Toggle %>
Open to work - <%= button_to toggle_open_to_work_user_settings_path, - method: :post, + <%= button_to user_settings_open_to_work_path, + method: :patch, class: "relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 #{user.open_to_work? ? 'bg-red-600' : 'bg-gray-400'}", data: { turbo_stream: true } do %> Toggle open to work diff --git a/app/views/users/_repository_tile.html.erb b/app/views/users/_repository_tile.html.erb index 773ae3a..eccb74e 100644 --- a/app/views/users/_repository_tile.html.erb +++ b/app/views/users/_repository_tile.html.erb @@ -79,8 +79,8 @@ <% if user && user_signed_in? && current_user == user %>
<% if hidden %> - <%= button_to unhide_repo_user_settings_path, - method: :post, + <%= button_to user_settings_repository_hide_path, + method: :delete, params: { repo_url: repository.github_url }, class: "p-1.5 rounded-full bg-gray-100 hover:bg-red-100 text-gray-500 hover:text-red-600 transition-colors opacity-0 group-hover:opacity-100 cursor-pointer", title: "Show repository", @@ -92,7 +92,7 @@ <% end %> <% else %> - <%= button_to hide_repo_user_settings_path, + <%= button_to user_settings_repository_hide_path, method: :post, params: { repo_url: repository.github_url }, class: "p-1.5 rounded-full bg-gray-100 hover:bg-red-100 text-gray-500 hover:text-red-600 transition-colors opacity-0 group-hover:opacity-100 cursor-pointer", diff --git a/app/views/users/_user_tile.html.erb b/app/views/users/_user_tile.html.erb index 4427dc2..a042177 100644 --- a/app/views/users/_user_tile.html.erb +++ b/app/views/users/_user_tile.html.erb @@ -32,7 +32,7 @@
<% unless local_assigns[:minimal] %> <% if user.bio.present? %> -

<%= user.bio %>

+

<%= user.bio %>

<% end %> <% end %> diff --git a/app/views/users/og_image.html.erb b/app/views/users/og_images/show.html.erb similarity index 100% rename from app/views/users/og_image.html.erb rename to app/views/users/og_images/show.html.erb diff --git a/bin/ci b/bin/ci new file mode 100755 index 0000000..5ac9976 --- /dev/null +++ b/bin/ci @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -e + +echo "=== Running CI Pipeline ===" +echo "" + +echo "📋 Step 1/5: Checking Ruby syntax..." +ruby -c app/**/*.rb lib/**/*.rb 2>/dev/null || true +echo "✅ Syntax check complete" +echo "" + +echo "🔍 Step 2/5: Running RuboCop..." +bundle exec rubocop -A --fail-level=E +echo "✅ RuboCop complete" +echo "" + +echo "🧪 Step 3/5: Running tests..." +rails test +echo "✅ Tests complete" +echo "" + +echo "🔒 Step 4/5: Running Brakeman security scan..." +bin/brakeman --no-pager +echo "✅ Security scan complete" +echo "" + +echo "🌐 Step 5/5: Checking i18n translations..." +bundle exec i18n-tasks health +echo "✅ i18n check complete" +echo "" + +echo "=== All CI checks passed! ===" diff --git a/bin/configure b/bin/configure new file mode 100755 index 0000000..1b8832f --- /dev/null +++ b/bin/configure @@ -0,0 +1,411 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "fileutils" +require "yaml" +require "securerandom" + +APP_ROOT = File.expand_path("..", __dir__) + +# Install lipgloss if not available +begin + require "lipgloss" +rescue LoadError + puts "Installing lipgloss gem for terminal styling..." + system("gem install lipgloss --no-document") || abort("Failed to install lipgloss gem") + Gem.clear_paths + require "lipgloss" +end + +def system!(*args) + system(*args, exception: true) +end + +def project_name_from_directory + File.basename(APP_ROOT) +end + +# Converts to Ruby module name for config/application.rb +# "my_project" -> "MyProject", "template" -> "Template", "MyApp" -> "MyApp" +def to_class_name(string) + return string if string.nil? || string.empty? + return string.split(/[-_]/).map(&:capitalize).join if string.match?(/[-_]/) + + string[0].upcase + string[1..].to_s +end + +# Converts to kebab-case for Docker service names, S3 buckets, etc. +# "ProjectName" -> "project-name" +def to_service_name(string) + string.gsub(/([A-Z])/, '-\1').downcase.sub(/^-/, "").gsub(/[^a-z0-9-]/, "-") +end + +# ───────────────────────────────────────────────────────────────────────────── +# Styles +# ───────────────────────────────────────────────────────────────────────────── + +VIOLET = "#7D56F4" +PURPLE = "#874BFD" +GREEN = "#04B575" +MUTED = "#626262" +WHITE = "#FAFAFA" +ORANGE = "#FF9F1C" +CYAN = "#00D9FF" +HOTPINK = "#FF1493" +GOLD = "#FFD700" +YELLOW = "#FFFF00" + +HEADER_STYLE = Lipgloss::Style.new + .border(:rounded) + .border_foreground(VIOLET) + .padding_top(1) + .padding_bottom(1) + .padding_left(4) + .padding_right(4) + .bold(true) + .foreground(WHITE) + +SECTION_STYLE = Lipgloss::Style.new + .foreground(PURPLE) + .bold(true) + .margin_top(1) + .margin_bottom(1) + +SUCCESS_STYLE = Lipgloss::Style.new + .foreground(GREEN) + +MUTED_STYLE = Lipgloss::Style.new + .foreground(MUTED) + .italic(true) + +SUMMARY_STYLE = Lipgloss::Style.new + .border(:rounded) + .border_foreground(GREEN) + .padding_top(1) + .padding_bottom(1) + .padding_left(2) + .padding_right(2) + +WARNING_STYLE = Lipgloss::Style.new + .foreground(ORANGE) + .bold(true) + +PROMPT_STYLE = Lipgloss::Style.new + .foreground(CYAN) + .bold(true) + +DEFAULT_STYLE = Lipgloss::Style.new + .foreground(MUTED) + +INPUT_STYLE = Lipgloss::Style.new + .foreground(GOLD) + +TIP_STYLE = Lipgloss::Style.new + .foreground(HOTPINK) + .bold(true) + +# ───────────────────────────────────────────────────────────────────────────── +# Output helpers +# ───────────────────────────────────────────────────────────────────────────── + +def header(text) = puts HEADER_STYLE.render(text) +def section(text) = puts SECTION_STYLE.render("── #{text} ──") +def success(text) = puts SUCCESS_STYLE.render(text) +def muted(text) = puts MUTED_STYLE.render(text) +def warning(text) = puts WARNING_STYLE.render(text) +def summary(text) = puts SUMMARY_STYLE.render(text) +def tip(text) = puts TIP_STYLE.render(text) + +def replace_in_file(file_path, old_content, new_content) + return unless File.exist?(file_path) + + content = File.read(file_path) + if content.include?(old_content) + File.write(file_path, content.gsub(old_content, new_content)) + success(" ✓ Updated #{file_path}") + end +end + +# ───────────────────────────────────────────────────────────────────────────── +# Prompts +# ───────────────────────────────────────────────────────────────────────────── + +# ANSI escape codes for cursor control +CURSOR_UP = "\e[1A" +CLEAR_LINE = "\e[2K" +RESET_COLOR = "\e[0m" + +# Convert hex color to ANSI true color escape sequence +def ansi_color(hex) + hex = hex.delete("#") + r = hex[0..1].to_i(16) + g = hex[2..3].to_i(16) + b = hex[4..5].to_i(16) + "\e[38;2;#{r};#{g};#{b}m" +end + +def prompt(question, default = nil) + label = PROMPT_STYLE.render(question) + colon = DEFAULT_STYLE.render(":") + + if default + default_hint = DEFAULT_STYLE.render(" [#{default}]") + print "#{label}#{default_hint}#{colon} #{ansi_color(YELLOW)}" + else + print "#{label}#{colon} #{ansi_color(YELLOW)}" + end + + answer = $stdin.gets.chomp + print RESET_COLOR + result = answer.empty? && default ? default : answer + + # Rewrite the line with the final value + print "#{CURSOR_UP}#{CLEAR_LINE}" + puts "#{label}#{colon} #{INPUT_STYLE.render(result)}" + + result +end + + +def confirm(question) + label = PROMPT_STYLE.render(question) + hint = DEFAULT_STYLE.render(" (y/N):") + print "#{label}#{hint} #{ansi_color(YELLOW)}" + + answer = $stdin.gets.chomp + print RESET_COLOR + result = answer.downcase == "y" + + # Rewrite the line with the final value + print "#{CURSOR_UP}#{CLEAR_LINE}" + puts "#{label}#{DEFAULT_STYLE.render(":")} #{INPUT_STYLE.render(result ? "Yes" : "No")}" + + result +end + +# ───────────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────────── + +FileUtils.chdir APP_ROOT do + puts + header("Rails Project Configuration") + puts + + if File.exist?(".configured") + warning("⚠️ This project appears to be already configured.") + puts + unless confirm("Do you want to reconfigure?") + puts "Exiting..." + exit 0 + end + puts + end + + directory_name = project_name_from_directory + suggested_name = to_class_name(directory_name) + + muted("Current directory: #{directory_name}") + puts + + # ── Project Identity ── + + section("Project Identity") + + project_name = prompt("Project name (CamelCase)", suggested_name) + project_name = to_class_name(project_name) + service_name = to_service_name(project_name) + + port = prompt("Development server port", "3000") + + # ── Deployment Configuration ── + + section("Deployment Configuration") + + domain = prompt("App domain (e.g., myapp.example.com)", "app.example.com") + + domain_parts = domain.split(".") + base_domain = domain_parts.length > 2 ? domain_parts[-2..].join(".") : domain + + admin_email = prompt("Admin email address", "admin@#{base_domain}") + + # ── Analytics ── + + section("Analytics") + + nullitics = false + + muted("Nullitics: privacy-first page analytics — no cookies, no PII, GDPR compliant.") + muted("Learn more at https://nullitics.com") + puts + + nullitics = confirm("Enable Nullitics analytics?") + + initializer_path = "config/initializers/nullitics.rb" + File.write(initializer_path, "Rails.configuration.x.nullitics = #{nullitics}\n") + success(" ✓ Updated #{initializer_path}") + + # ── Apply Configuration ── + + section("Renaming project from Template to #{project_name}") + + replace_in_file("config/application.rb", "module Template", "module #{project_name}") + replace_in_file("Rakefile", "Template::Application", "#{project_name}::Application") + + %w[development test production].each do |env| + replace_in_file("config/environments/#{env}.rb", "Template::Application", "#{project_name}::Application") + end + + replace_in_file("config.ru", "Template::Application", "#{project_name}::Application") + + if File.exist?("package.json") + replace_in_file("package.json", '"name": "template"', "\"name\": \"#{directory_name}\"") + end + + replace_in_file("config/locales/en.yml", "app_name: Template", "app_name: #{project_name}") + replace_in_file("app/views/pwa/manifest.json.erb", "Template", project_name) + replace_in_file("public/site.webmanifest", "Template", project_name) + + section("Configuring deploy files") + + replace_in_file("config/deploy.yml", "service: template", "service: #{service_name}") + replace_in_file("config/deploy.yml", "image: template", "image: #{service_name}") + replace_in_file("config/deploy.yml", "app.example.com", domain) + replace_in_file("config/deploy.yml", "template_storage:/rails/storage", "#{service_name}_storage:/rails/storage") + + replace_in_file("config/environments/production.rb", 'host: "app.example.com"', "host: \"#{domain}\"") + replace_in_file("config/environments/production.rb", '"app.example.com"', "\"#{domain}\"") + replace_in_file("config/environments/production.rb", '/.*\.example\.com/', "/.*\\.#{Regexp.escape(base_domain).gsub('\\.', '\\.')}/") + replace_in_file("config/environments/production.rb", 'from: "noreply@app.example.com"', "from: \"noreply@#{base_domain}\"") + + replace_in_file("Dockerfile", "docker build -t template", "docker build -t #{service_name}") + replace_in_file("Dockerfile", "--name template template", "--name #{service_name} #{service_name}") + + if port != "3000" + replace_in_file("Procfile.dev", "bin/rails server", "bin/rails server -p #{port}") + replace_in_file("config/environments/development.rb", "port: 3000", "port: #{port}") + end + + # Update seeds + File.write("db/seeds.rb", <<~RUBY) + # This file should ensure the existence of records required to run the application in every environment (production, + # development, test). The code here should be idempotent so that it can be executed at any point in every environment. + # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). + + # Create first admin + admin = Admin.find_or_create_by!(email: "#{admin_email}") + puts "✓ Admin created: \#{admin.email}" + + # Set default mail from address + setting = Setting.instance + setting.update!(mail_from: "noreply@#{base_domain}") if setting.mail_from.blank? + puts "✓ Mail from: \#{setting.mail_from}" + RUBY + success(" ✓ Updated db/seeds.rb") + + # ── Consolidate Migrations ── + + section("Consolidating migrations") + + schema_path = "db/schema.rb" + migrate_dir = "db/migrate" + + if !File.exist?(schema_path) + warning(" ⚠ db/schema.rb not found, skipping migration consolidation") + elsif !Dir.exist?(migrate_dir) + warning(" ⚠ db/migrate/ not found, skipping migration consolidation") + elsif Dir.glob("#{migrate_dir}/*.rb").size <= 1 + muted(" Already consolidated (1 or fewer migrations)") + else + schema_content = File.read(schema_path) + schema_match = schema_content.match(/ActiveRecord::Schema\[[\d.]+\]\.define\(.*?\) do\n(.*)\nend\n?\z/m) + + if schema_match.nil? + warning(" ⚠ Could not parse db/schema.rb, skipping migration consolidation") + else + schema_body = schema_match[1] + + # Build migration with correct indentation + timestamp = Time.now.strftime("%Y%m%d%H%M%S") + migration = +"class CreateInitialSchema < ActiveRecord::Migration[8.2]\n" + migration << " def change\n" + schema_body.each_line do |line| + migration << (line.strip.empty? ? "\n" : " #{line}") + end + migration << "\n" unless migration.end_with?("\n") + migration << " end\nend\n" + + # Delete all old migrations + old_migrations = Dir.glob("#{migrate_dir}/*.rb") + old_migrations.each { |f| File.delete(f) } + muted(" Removed #{old_migrations.size} migration files") + + # Write consolidated migration + migration_path = "#{migrate_dir}/#{timestamp}_create_initial_schema.rb" + File.write(migration_path, migration) + success(" ✓ Created #{migration_path}") + + # Update schema.rb version to match new migration timestamp + new_version = timestamp.gsub(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, '\1_\2_\3_\4\5\6') + updated_schema = schema_content.sub( + /ActiveRecord::Schema\[[\d.]+\]\.define\(version: \d[\d_]*\)/, + "ActiveRecord::Schema[8.2].define(version: #{new_version})" + ) + File.write(schema_path, updated_schema) + success(" ✓ Updated schema.rb version") + end + end + + File.write(".configured", "Configured on #{Time.now}\nProject: #{project_name}\nDomain: #{domain}\nAdmin: #{admin_email}\n") + + # ── Git Remote ── + + section("Configuring git remote") + + # Rename origin to template (keep reference to template repo) + if system("git remote get-url origin > /dev/null 2>&1") + system("git remote rename origin template") + success(" ✓ Renamed origin to template") + end + + puts + muted("Enter your repository URL to set as origin (or press Enter to skip):") + repo_url = prompt("Repository URL (e.g., git@github.com:user/repo.git)") + + unless repo_url.to_s.empty? + system("git", "remote", "add", "origin", repo_url) + success(" ✓ Set origin to #{repo_url}") + end + + # Show tip about updating from template + puts + tip("💡 TIP: The original template is now available as 'template' remote.") + tip(" To pull future updates and new features from the template:") + tip("") + tip(" git fetch template") + tip(" git merge template/main") + puts + + # ── Commit ── + + section("Committing configuration") + commit_message = <<~MSG + Configure project: #{project_name} + + Domain: #{domain} + Admin: #{admin_email} + MSG + system("git add -A") + if system("git", "commit", "-m", commit_message.strip) + success(" ✓ Configuration committed") + else + muted(" (no changes to commit)") + end + + puts + header("✓ Configuration Complete!") + puts + tip("💡 TIP: Configure API keys (AI, Stripe, SMTP, Litestream) in the admin panel:") + tip(" /madmin/settings") + puts +end diff --git a/bin/hooks/pre-commit b/bin/hooks/pre-commit new file mode 100755 index 0000000..c1f14cc --- /dev/null +++ b/bin/hooks/pre-commit @@ -0,0 +1,12 @@ +#!/bin/bash +# Pre-commit hook - runs CI checks before allowing commit + +echo "Running pre-commit checks..." +bin/ci + +if [ $? -ne 0 ]; then + echo "" + echo "Pre-commit checks failed. Commit aborted." + echo "Fix the issues above and try again." + exit 1 +fi diff --git a/bin/setup b/bin/setup index be3db3c..35baf11 100755 --- a/bin/setup +++ b/bin/setup @@ -10,15 +10,17 @@ end FileUtils.chdir APP_ROOT do # This script is a way to set up or update your development environment automatically. # This script is idempotent, so that you can run it at any time and get an expectable outcome. - # Add necessary setup steps to this file. puts "== Installing dependencies ==" system("bundle check") || system!("bundle install") - # puts "\n== Copying sample files ==" - # unless File.exist?("config/database.yml") - # FileUtils.cp "config/database.yml.sample", "config/database.yml" - # end + # Run configuration wizard if not already configured + unless File.exist?(".configured") + system!("bin/configure") + end + + puts "\n== Configuring git hooks ==" + system!("git config core.hooksPath bin/hooks") puts "\n== Preparing database ==" system! "bin/rails db:prepare" @@ -29,6 +31,8 @@ FileUtils.chdir APP_ROOT do unless ARGV.include?("--skip-server") puts "\n== Starting development server ==" STDOUT.flush # flush the output before exec(2) so that it displays + # rubocop:disable Lint/UnreachableCode -- exec replaces process exec "bin/dev" + # rubocop:enable Lint/UnreachableCode end end diff --git a/config/application.rb b/config/application.rb index d72df41..eaaa7fa 100644 --- a/config/application.rb +++ b/config/application.rb @@ -14,8 +14,9 @@ class Application < Rails::Application # Please, add to the `ignore` list any other `lib` subdirectories that do # not contain `.rb` files, or that should not be reloaded or eager loaded. # Common ones are `templates`, `generators`, or `middleware`, for example. - config.autoload_lib(ignore: %w[assets tasks middleware]) + config.autoload_lib(ignore: %w[assets tasks middleware generators]) + # Block malicious requests (WordPress exploits, PHP files, etc.) early require_relative "../lib/middleware/malicious_path_blocker" config.middleware.insert_before Rails::Rack::Logger, Middleware::MaliciousPathBlocker @@ -27,6 +28,9 @@ class Application < Rails::Application # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + # Load locale files from nested directories + config.i18n.load_path += Dir[Rails.root.join("config/locales/**/*.yml")] + # Allow requests from different ports in development config.hosts.clear if Rails.env.development? end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 42f14ea..af8d5c7 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,10 +1,10 @@ { "ignored_warnings": [ { - "fingerprint": "09be3afbde20d9ac08a0d7c169e26aad44e50e0ef7491d0a7d8e4542f9a74a75", - "note": "False positive: source_path comes from ActiveStorage temporary files, not user input. The command uses the safe array form of system(*cmd) which prevents shell injection. Each argument is passed separately to the command without shell interpretation. The path originates from @blob.open which creates managed temporary files from our storage system." + "fingerprint": "f742e0768c098181ee00fce564852affc16a270d4c42c73fb3b249f551a300b4", + "note": "False positive: source_path comes from ActiveStorage temporary files, not user input. The command uses the safe array form of system(*cmd) which prevents shell injection. Each argument is passed separately to the command without shell interpretation. The path originates from featured_image.blob.open which creates managed temporary files from our storage system." } ], - "updated": "2025-08-14 16:56:00 +0200", - "brakeman_version": "7.1.0" -} \ No newline at end of file + "updated": "2026-04-06 19:48:09 +0200", + "brakeman_version": "8.0.4" +} diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc new file mode 100644 index 0000000..7bc5e06 --- /dev/null +++ b/config/credentials/test.yml.enc @@ -0,0 +1 @@ +4p8Ra0o42DsXp1WW7cGlGbhKJykm1UXAe9ERNwThg/t5r/I9hMWAFzBw0j0BjfJe5m55EzVgxIG0WHqCXKSMbtp2YD8lbpeKTiXe/OMuANKE5TcLiwbnauDltcPYhX0rBAZSDVoDCoNetZzUJ27XUFRI2F1pZQJv0oTLrmLayy0nURs=--NnXgRP7rvSPbao+P--vpWvluXgCnOROKW8WY8gmA== \ No newline at end of file diff --git a/config/deploy.yml b/config/deploy.yml index c5e4d78..3101ac5 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -43,7 +43,6 @@ env: # WEB_CONCURRENCY: 2 # Match this to any external database server to configure Active Record correctly - # Use template-db for a db accessory server on same machine via local kamal docker network. # DB_HOST: 192.168.0.2 # Log everything from Rails @@ -75,7 +74,6 @@ asset_path: /rails/public/assets # Configure the image builder. builder: arch: amd64 - # Pass secrets to the Docker build process secrets: - RAILS_MASTER_KEY - MAXMIND_ACCOUNT_ID diff --git a/config/environments/development.rb b/config/environments/development.rb index 799c1e6..a1560e1 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -31,7 +31,8 @@ # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local - # Don't care if the mailer can't send. + # Open emails in browser automatically + config.action_mailer.delivery_method = :browser_preview config.action_mailer.raise_delivery_errors = false # Make template changes take effect immediately. @@ -44,6 +45,9 @@ # Open emails in browser instead of sending config.action_mailer.delivery_method = :browser_preview + # Default from address for emails. + config.action_mailer.default_options = { from: "noreply@localhost" } + # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log @@ -59,8 +63,11 @@ # Highlight code that enqueued background job in logs. config.active_job.verbose_enqueue_logs = true + # Fall back to English for missing translations in non-English locales. + config.i18n.fallbacks = true + # Raises error for missing translations. - # config.i18n.raise_on_missing_translations = true + config.i18n.raise_on_missing_translations = true # Annotate rendered view with file names. config.action_view.annotate_rendered_view_with_filenames = true @@ -73,4 +80,12 @@ # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. # config.generators.apply_rubocop_autocorrect_after_generate! + + # # Tidewave: If you are using multiple hosts/subdomains during development, you must use *.localhost, + # # as such domains are considered secure by browsers. Additionally, add the following to config/initializers/development.rb: + # config.session_store :cookie_store, + # key: "__template_session", + # same_site: :none, + # secure: true, + # assume_ssl: true end diff --git a/config/environments/production.rb b/config/environments/production.rb index cafc287..5449b2b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -60,16 +60,8 @@ # Set host to be used by links generated in mailer templates. config.action_mailer.default_url_options = { host: "whyruby.info" } - config.action_mailer.default_options = { from: "hey@yurisidorov.com" } - # Specify outgoing SMTP server. - config.action_mailer.smtp_settings = { - user_name: Rails.application.credentials.dig(:smtp, :username), - password: Rails.application.credentials.dig(:smtp, :password), - address: Rails.application.credentials.dig(:smtp, :address) || "smtp.example.com", - port: 587, - authentication: :plain - } + # SMTP settings are configured dynamically via Setting model (Madmin admin panel) # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). @@ -82,11 +74,13 @@ config.active_record.attributes_for_inspect = [ :id ] # Enable DNS rebinding protection and other `Host` header attacks. - # config.hosts = [ - # "example.com", # Allow requests from example.com - # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` - # ] - # + config.hosts = [ + "whyruby.info", + "rubycommunity.org", + /.*\.whyruby\.info/, + /.*\.rubycommunity\.org/ + ] + # Skip DNS rebinding protection for the default health check endpoint. - # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } + config.host_authorization = { exclude: ->(request) { request.path == "/up" } } end diff --git a/config/environments/test.rb b/config/environments/test.rb index 0bbb8e0..dfba1cc 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -40,9 +40,15 @@ config.action_mailer.default_url_options = { host: "example.com" } config.action_mailer.default_options = { from: "hey@yurisidorov.com" } + # Default from address for emails. + config.action_mailer.default_options = { from: "noreply@example.com" } + # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr + # Fall back to English for missing translations in non-English locales. + config.i18n.fallbacks = true + # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml new file mode 100644 index 0000000..6b57c79 --- /dev/null +++ b/config/i18n-tasks.yml @@ -0,0 +1,70 @@ +# i18n-tasks configuration +# https://github.com/glebm/i18n-tasks + +base_locale: en +locales: [en] + +# Paths to translation files +data: + read: + - config/locales/%{locale}.yml + - config/locales/%{locale}/**/*.yml + +# Paths to scan for translation keys +search: + paths: + - app/ + + # Patterns to ignore + exclude: + - app/assets/ + +# Report missing and unused keys +ignore_missing: + - 'activerecord.*' + - 'errors.*' + - 'helpers.select.*' + - 'number.*' + - 'date.*' + - 'time.*' + - 'support.*' + - 'helpers.submit.*' + +ignore_unused: + - 'activerecord.*' + - 'date.*' + - 'time.*' + - 'number.*' + - 'support.*' + # Allow common keys to be defined even if not always used + - 'common.*' + - 'app_name' + - 'messages.count' + # Mobility translation keys are dynamic + - 'mobility.*' + # WhyRuby: country codes used dynamically via country_name helper + - 'countries.*' + # WhyRuby: meta tags referenced in content_for :head blocks + - 'meta.*' + # WhyRuby: shared YAML anchors referenced throughout + - 'shared.*' + # WhyRuby: sort labels used dynamically in users/index + - 'users.index.sort.*' + - 'users.index.sort_label' + - 'users.index.title' + - 'users.index.description' + - 'users.index.join' + - 'users.index.join_suffix' + - 'users.index.keywords' + - 'users.repository_tile.*' + # WhyRuby: auth and controller messages + - 'auth.*' + - 'controllers.*' + # Template home page keys (kept for reference, overridden by our views) + - 'home.index.*' + # Languages and OG image + - 'languages.*' + - 'og_image.*' + - 'language_name' + - 'native_name' + - 'hello' diff --git a/config/initializers/avo.rb b/config/initializers/avo.rb deleted file mode 100644 index d3e395b..0000000 --- a/config/initializers/avo.rb +++ /dev/null @@ -1,163 +0,0 @@ -# For more information regarding these settings check out our docs https://docs.avohq.io -# The values disaplayed here are the default ones. Uncomment and change them to fit your needs. -Avo.configure do |config| - ## == Routing == - config.root_path = "/avo" - # used only when you have custom `map` configuration in your config.ru - # config.prefix_path = "/internal" - - # Sometimes you might want to mount Avo's engines yourself. - # https://docs.avohq.io/3.0/routing.html - # config.mount_avo_engines = true - - # Where should the user be redirected when visiting the `/avo` url - # config.home_path = nil - - ## == Licensing == - # config.license_key = ENV['AVO_LICENSE_KEY'] - - ## == Set the context == - config.set_context do - # Return a context object that gets evaluated within Avo::ApplicationController - end - - ## == Authentication == - # config.current_user_method = :current_user - # config.authenticate_with do - # end - - ## == Authorization == - # config.is_admin_method = :is_admin - # config.is_developer_method = :is_developer - # config.authorization_methods = { - # index: 'index?', - # show: 'show?', - # edit: 'edit?', - # new: 'new?', - # update: 'update?', - # create: 'create?', - # destroy: 'destroy?', - # search: 'search?', - # } - # config.raise_error_on_missing_policy = false - config.authorization_client = nil - config.explicit_authorization = true - - ## == Localization == - # config.locale = 'en-US' - - ## == Resource options == - # config.resource_controls_placement = :right - # config.model_resource_mapping = {} - # config.default_view_type = :table - # config.per_page = 24 - # config.per_page_steps = [12, 24, 48, 72] - # config.via_per_page = 8 - # config.id_links_to_resource = false - # config.pagination = -> do - # { - # type: :default, - # size: 9, # `[1, 2, 2, 1]` for pagy < 9.0 - # } - # end - - ## == Response messages dismiss time == - # config.alert_dismiss_time = 5000 - - - ## == Number of search results to display == - # config.search_results_count = 8 - - ## == Associations lookup list limit == - # config.associations_lookup_list_limit = 1000 - - ## == Cache options == - ## Provide a lambda to customize the cache store used by Avo. - ## We compute the cache store by default, this is NOT the default, just an example. - # config.cache_store = -> { - # ActiveSupport::Cache.lookup_store(:solid_cache_store) - # } - # config.cache_resources_on_index_view = true - ## permanent enable or disable cache_resource_filters, default value is false - # config.cache_resource_filters = false - ## provide a lambda to enable or disable cache_resource_filters per user/resource. - # config.cache_resource_filters = -> { current_user.cache_resource_filters? } - - ## == Turbo options == - # config.turbo = -> do - # { - # instant_click: true - # } - # end - - ## == Logger == - # config.logger = -> { - # file_logger = ActiveSupport::Logger.new(Rails.root.join("log", "avo.log")) - # - # file_logger.datetime_format = "%Y-%m-%d %H:%M:%S" - # file_logger.formatter = proc do |severity, time, progname, msg| - # "[Avo] #{time}: #{msg}\n".tap do |i| - # puts i - # end - # end - # - # file_logger - # } - - ## == Customization == - config.click_row_to_view_record = true - config.app_name = "WhyRuby Admin" - # config.timezone = 'UTC' - # config.currency = 'USD' - # config.hide_layout_when_printing = false - # config.full_width_container = false - # config.full_width_index_view = false - # config.search_debounce = 300 - # config.view_component_path = "app/components" - # config.display_license_request_timeout_error = true - # config.disabled_features = [] - # config.buttons_on_form_footers = true - # config.field_wrapper_layout = true - # config.resource_parent_controller = "Avo::ResourcesController" - # config.first_sorting_option = :desc # :desc or :asc - - ## == Branding == - # config.branding = { - # colors: { - # background: "248 246 242", - # 100 => "#CEE7F8", - # 400 => "#399EE5", - # 500 => "#0886DE", - # 600 => "#066BB2", - # }, - # chart_colors: ["#0B8AE2", "#34C683", "#2AB1EE", "#34C6A8"], - # logo: "/avo-assets/logo.png", - # logomark: "/avo-assets/logomark.png", - # placeholder: "/avo-assets/placeholder.svg", - # favicon: "/avo-assets/favicon.ico" - # } - - ## == Breadcrumbs == - # config.display_breadcrumbs = true - # config.set_initial_breadcrumbs do - # add_breadcrumb "Home", '/avo' - # end - - ## == Menus == - # config.main_menu = -> { - # section "Dashboards", icon: "avo/dashboards" do - # all_dashboards - # end - - # section "Resources", icon: "avo/resources" do - # all_resources - # end - - # section "Tools", icon: "avo/tools" do - # all_tools - # end - # } - # config.profile_menu = -> { - # link "Profile", path: "/avo/profile", icon: "heroicons/outline/user-circle" - # } -end diff --git a/config/initializers/avo_friendly_id_patch.rb b/config/initializers/avo_friendly_id_patch.rb deleted file mode 100644 index b86a7af..0000000 --- a/config/initializers/avo_friendly_id_patch.rb +++ /dev/null @@ -1,120 +0,0 @@ -# Patch Avo controllers to handle FriendlyId slugs properly -Rails.application.config.after_initialize do - # Patch ActionsController for bulk actions and single-record actions - if defined?(Avo::ActionsController) - Avo::ActionsController.class_eval do - # Store original methods - check both public and private - if private_method_defined?(:set_query) - alias_method :original_set_query, :set_query - end - - if method_defined?(:set_record) || private_method_defined?(:set_record) - alias_method :original_set_record, :set_record - end - - private - - # Override set_query to handle FriendlyId slugs - def set_query - Rails.logger.info "[Avo Patch] set_query called" if Rails.env.development? - - # Handle bulk actions with multiple IDs - if params[:fields] && params[:fields][:avo_resource_ids].present? - ids = params[:fields][:avo_resource_ids] - - Rails.logger.info "[Avo Patch] Bulk action with IDs: #{ids}" if Rails.env.development? - - # Ensure we have a resource - if @resource.nil? && params[:resource_name].present? - # Whitelist of allowed resource names to prevent arbitrary code execution - allowed_resources = %w[categories comments posts reports tags users] - resource_name = params[:resource_name].to_s.downcase.singularize - - if allowed_resources.include?(resource_name) - resource_class = "Avo::Resources::#{resource_name.camelize}".safe_constantize - @resource = resource_class.new if resource_class - else - Rails.logger.warn "[Avo Patch] Attempted to access invalid resource: #{params[:resource_name]}" - end - end - - # Find all records using our custom finder - if @resource && @resource.class.respond_to?(:find_records) - @query = @resource.class.find_records(ids, params: params) - Rails.logger.info "[Avo Patch] Found #{@query.size} records using find_records" if Rails.env.development? - else - # Fallback to finding individually - id_list = ids.is_a?(String) ? ids.split(",").map(&:strip) : ids - @query = id_list.map do |id| - begin - if @resource && @resource.class.respond_to?(:find_record) - @resource.class.find_record(id, params: params) - elsif @resource && @resource.model_class - @resource.model_class.friendly.find(id) - end - rescue ActiveRecord::RecordNotFound - Rails.logger.info "[Avo Patch] Record not found for ID: #{id}" if Rails.env.development? - nil - end - end.compact - Rails.logger.info "[Avo Patch] Found #{@query.size} records via individual lookup" if Rails.env.development? - end - - # CRITICAL: Ensure @query is always an array for bulk actions - @query = Array(@query) unless @query.is_a?(Array) - - Rails.logger.info "[Avo Patch] Final @query: #{@query.class} with #{@query.size} items" if Rails.env.development? - elsif respond_to?(:original_set_query, true) - # Not a bulk action, use original behavior - original_set_query - else - # Fallback if no original method - @query = @record || (@resource&.model_class&.none) - end - rescue => e - Rails.logger.error "[Avo Patch] Error in set_query: #{e.message}\n#{e.backtrace.first(5).join("\n")}" - @query = [] - end - - # Override set_record to handle our modified @query AND single-record actions with slugs - def set_record - Rails.logger.info "[Avo Patch] set_record called with params[:id]=#{params[:id].inspect}" if Rails.env.development? - - if params[:fields] && params[:fields][:avo_resource_ids].present? && !params[:id].present? - # For bulk actions, don't try to set a single record - # @query should already be an array from our set_query - Rails.logger.info "[Avo Patch] Bulk action detected, skipping single record logic" if Rails.env.development? - @record = nil - elsif params[:id].present? - # For single record actions, ALWAYS use our custom find_record - # This handles both regular navigation and single-record actions - Rails.logger.info "[Avo Patch] Single record action, using custom find_record" if Rails.env.development? - - if @resource && @resource.class.respond_to?(:find_record) - @record = @resource.class.find_record(params[:id], params: params) - Rails.logger.info "[Avo Patch] Found record: #{@record.class}##{@record.id}" if Rails.env.development? && @record - elsif @resource && @resource.model_class - # Fallback to model with friendly finders - @record = @resource.model_class.find(params[:id]) - else - # Call original if no resource available - original_set_record if respond_to?(:original_set_record, true) - end - elsif @query.respond_to?(:size) && @query.size == 1 - # ActionsController's special case: single item in query - @record = @query.first - Rails.logger.info "[Avo Patch] Set record from single-item query" if Rails.env.development? - else - @record = nil - end - rescue ActiveRecord::RecordNotFound => e - Rails.logger.error "[Avo Patch] Record not found: #{e.message}" - flash[:error] = e.message - @record = nil - rescue => e - Rails.logger.error "[Avo Patch] Error in set_record: #{e.message}" - @record = nil - end - end - end -end diff --git a/config/initializers/browser_preview_delivery.rb b/config/initializers/browser_preview_delivery.rb index f198811..03e98e7 100644 --- a/config/initializers/browser_preview_delivery.rb +++ b/config/initializers/browser_preview_delivery.rb @@ -1,19 +1,27 @@ -# frozen_string_literal: true - -# Custom delivery method that opens emails in the browser for development testing. -# Writes the email to a temp file and opens it in the default browser. - +# Custom delivery method that opens emails in the browser automatically class BrowserPreviewDelivery def initialize(settings) @settings = settings end def deliver!(mail) - tmp = Tempfile.new([ "email_preview", ".html" ]) - tmp.write(mail.html_part&.body&.decoded || mail.body.decoded) - tmp.close - system("open", tmp.path) + # Create tmp directory if it doesn't exist + tmp_dir = Rails.root.join("tmp", "mails") + FileUtils.mkdir_p(tmp_dir) + + # Generate a unique filename + filename = "#{Time.now.strftime('%Y%m%d_%H%M%S')}_#{mail.subject&.parameterize || 'mail'}.html" + filepath = tmp_dir.join(filename) + + # Write the email HTML to file + File.write(filepath, mail.html_part&.body || mail.body) + + # Open in browser + system("open", filepath.to_s) + + Rails.logger.info "Email opened in browser: #{filepath}" end end +# Register the delivery method ActionMailer::Base.add_delivery_method :browser_preview, BrowserPreviewDelivery diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb deleted file mode 100644 index c592e6f..0000000 --- a/config/initializers/devise.rb +++ /dev/null @@ -1,336 +0,0 @@ -# frozen_string_literal: true - -# Assuming you have not yet modified this file, each configuration option below -# is set to its default value. Note that some are commented out while others -# are not: uncommented lines are intended to protect your configuration from -# breaking changes in upgrades (i.e., in the event that future versions of -# Devise change the default values for those options). -# -# Use this hook to configure devise mailer, warden hooks and so forth. -# Many of these configuration options can be set straight in your model. -Devise.setup do |config| - # The secret key used by Devise. Devise uses this key to generate - # random tokens. Changing this key will render invalid all existing - # confirmation, reset password and unlock tokens in the database. - # Devise will use the `secret_key_base` as its `secret_key` - # by default. You can change it below and use your own secret key. - # config.secret_key = '7390f07dbfef53536269d11f5c4682810493afeffb8aa5f5c5a3590d602479686ff39a6a67f8420513ad093af87ede0acabe76a4ee30c9945a2a54d96b2f8f4a' - - # ==> Controller configuration - # Configure the parent class to the devise controllers. - # config.parent_controller = 'DeviseController' - - # ==> Mailer Configuration - # Configure the e-mail address which will be shown in Devise::Mailer, - # note that it will be overwritten if you use your own mailer class - # with default "from" parameter. - config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com" - - # Configure the class responsible to send e-mails. - # config.mailer = 'Devise::Mailer' - - # Configure the parent class responsible to send e-mails. - # config.parent_mailer = 'ActionMailer::Base' - - # ==> ORM configuration - # Load and configure the ORM. Supports :active_record (default) and - # :mongoid (bson_ext recommended) by default. Other ORMs may be - # available as additional gems. - require "devise/orm/active_record" - - # ==> Configuration for any authentication mechanism - # Configure which keys are used when authenticating a user. The default is - # just :email. You can configure it to use [:username, :subdomain], so for - # authenticating a user, both parameters are required. Remember that those - # parameters are used only when authenticating and not when retrieving from - # session. If you need permissions, you should implement that in a before filter. - # You can also supply a hash where the value is a boolean determining whether - # or not authentication should be aborted when the value is not present. - # config.authentication_keys = [:email] - - # Configure parameters from the request object used for authentication. Each entry - # given should be a request method and it will automatically be passed to the - # find_for_authentication method and considered in your model lookup. For instance, - # if you set :request_keys to [:subdomain], :subdomain will be used on authentication. - # The same considerations mentioned for authentication_keys also apply to request_keys. - # config.request_keys = [] - - # Configure which authentication keys should be case-insensitive. - # These keys will be downcased upon creating or modifying a user and when used - # to authenticate or find a user. Default is :email. - config.case_insensitive_keys = [ :email ] - - # Configure which authentication keys should have whitespace stripped. - # These keys will have whitespace before and after removed upon creating or - # modifying a user and when used to authenticate or find a user. Default is :email. - config.strip_whitespace_keys = [ :email ] - - # Tell if authentication through request.params is enabled. True by default. - # It can be set to an array that will enable params authentication only for the - # given strategies, for example, `config.params_authenticatable = [:database]` will - # enable it only for database (email + password) authentication. - # config.params_authenticatable = true - - # Tell if authentication through HTTP Auth is enabled. False by default. - # It can be set to an array that will enable http authentication only for the - # given strategies, for example, `config.http_authenticatable = [:database]` will - # enable it only for database authentication. - # For API-only applications to support authentication "out-of-the-box", you will likely want to - # enable this with :database unless you are using a custom strategy. - # The supported strategies are: - # :database = Support basic authentication with authentication key + password - # config.http_authenticatable = false - - # If 401 status code should be returned for AJAX requests. True by default. - # config.http_authenticatable_on_xhr = true - - # The realm used in Http Basic Authentication. 'Application' by default. - # config.http_authentication_realm = 'Application' - - # It will change confirmation, password recovery and other workflows - # to behave the same regardless if the e-mail provided was right or wrong. - # Does not affect registerable. - # config.paranoid = true - - # By default Devise will store the user in session. You can skip storage for - # particular strategies by setting this option. - # Notice that if you are skipping storage for all authentication paths, you - # may want to disable generating routes to Devise's sessions controller by - # passing skip: :sessions to `devise_for` in your config/routes.rb - config.skip_session_storage = [ :http_auth ] - - # By default, Devise cleans up the CSRF token on authentication to - # avoid CSRF token fixation attacks. This means that, when using AJAX - # requests for sign in and sign up, you need to get a new CSRF token - # from the server. You can disable this option at your own risk. - # config.clean_up_csrf_token_on_authentication = true - - # When false, Devise will not attempt to reload routes on eager load. - # This can reduce the time taken to boot the app but if your application - # requires the Devise mappings to be loaded during boot time the application - # won't boot properly. - # config.reload_routes = true - - # ==> Configuration for :database_authenticatable - # For bcrypt, this is the cost for hashing the password and defaults to 12. If - # using other algorithms, it sets how many times you want the password to be hashed. - # The number of stretches used for generating the hashed password are stored - # with the hashed password. This allows you to change the stretches without - # invalidating existing passwords. - # - # Limiting the stretches to just one in testing will increase the performance of - # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use - # a value less than 10 in other environments. Note that, for bcrypt (the default - # algorithm), the cost increases exponentially with the number of stretches (e.g. - # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). - config.stretches = Rails.env.test? ? 1 : 12 - - # Set up a pepper to generate the hashed password. - # config.pepper = '4eeb91854caf43cd8155553e0c017b7ad1fdcf6cd862273a103f02f54f568868ca036bef3897d9933ea1983816056e31e086be202373423cdaa5c44aa8216dd4' - - # Send a notification to the original email when the user's email is changed. - # config.send_email_changed_notification = false - - # Send a notification email when the user's password is changed. - # config.send_password_change_notification = false - - # ==> Configuration for :confirmable - # A period that the user is allowed to access the website even without - # confirming their account. For instance, if set to 2.days, the user will be - # able to access the website for two days without confirming their account, - # access will be blocked just in the third day. - # You can also set it to nil, which will allow the user to access the website - # without confirming their account. - # Default is 0.days, meaning the user cannot access the website without - # confirming their account. - # config.allow_unconfirmed_access_for = 2.days - - # A period that the user is allowed to confirm their account before their - # token becomes invalid. For example, if set to 3.days, the user can confirm - # their account within 3 days after the mail was sent, but on the fourth day - # their account can't be confirmed with the token any more. - # Default is nil, meaning there is no restriction on how long a user can take - # before confirming their account. - # config.confirm_within = 3.days - - # If true, requires any email changes to be confirmed (exactly the same way as - # initial account confirmation) to be applied. Requires additional unconfirmed_email - # db field (see migrations). Until confirmed, new email is stored in - # unconfirmed_email column, and copied to email column on successful confirmation. - config.reconfirmable = true - - # Defines which key will be used when confirming an account - # config.confirmation_keys = [:email] - - # ==> Configuration for :rememberable - # The time the user will be remembered without asking for credentials again. - # config.remember_for = 2.weeks - - # Invalidates all the remember me tokens when the user signs out. - config.expire_all_remember_me_on_sign_out = true - - # If true, extends the user's remember period when remembered via cookie. - # config.extend_remember_period = false - - # Options to be passed to the created cookie. For instance, you can set - # secure: true in order to force SSL only cookies. - # config.rememberable_options = {} - - # ==> Configuration for :validatable - # Range for password length. - config.password_length = 6..128 - - # Email regex used to validate email formats. It simply asserts that - # one (and only one) @ exists in the given string. This is mainly - # to give user feedback and not to assert the e-mail validity. - config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ - - # ==> Configuration for :timeoutable - # The time you want to timeout the user session without activity. After this - # time the user will be asked for credentials again. Default is 30 minutes. - # config.timeout_in = 30.minutes - - # ==> Configuration for :lockable - # Defines which strategy will be used to lock an account. - # :failed_attempts = Locks an account after a number of failed attempts to sign in. - # :none = No lock strategy. You should handle locking by yourself. - # config.lock_strategy = :failed_attempts - - # Defines which key will be used when locking and unlocking an account - # config.unlock_keys = [:email] - - # Defines which strategy will be used to unlock an account. - # :email = Sends an unlock link to the user email - # :time = Re-enables login after a certain amount of time (see :unlock_in below) - # :both = Enables both strategies - # :none = No unlock strategy. You should handle unlocking by yourself. - # config.unlock_strategy = :both - - # Number of authentication tries before locking an account if lock_strategy - # is failed attempts. - # config.maximum_attempts = 20 - - # Time interval to unlock the account if :time is enabled as unlock_strategy. - # config.unlock_in = 1.hour - - # Warn on the last attempt before the account is locked. - # config.last_attempt_warning = true - - # ==> Configuration for :recoverable - # - # Defines which key will be used when recovering the password for an account - # config.reset_password_keys = [:email] - - # Time interval you can reset your password with a reset password key. - # Don't put a too small interval or your users won't have the time to - # change their passwords. - config.reset_password_within = 6.hours - - # When set to false, does not sign a user in automatically after their password is - # reset. Defaults to true, so a user is signed in automatically after a reset. - # config.sign_in_after_reset_password = true - - # ==> Configuration for :encryptable - # Allow you to use another hashing or encryption algorithm besides bcrypt (default). - # You can use :sha1, :sha512 or algorithms from others authentication tools as - # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20 - # for default behavior) and :restful_authentication_sha1 (then you should set - # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper). - # - # Require the `devise-encryptable` gem when using anything other than bcrypt - # config.encryptor = :sha512 - - # ==> Scopes configuration - # Turn scoped views on. Before rendering "sessions/new", it will first check for - # "users/sessions/new". It's turned off by default because it's slower if you - # are using only default views. - # config.scoped_views = false - - # Configure the default scope given to Warden. By default it's the first - # devise role declared in your routes (usually :user). - # config.default_scope = :user - - # Set this configuration to false if you want /users/sign_out to sign out - # only the current scope. By default, Devise signs out all scopes. - # config.sign_out_all_scopes = true - - # ==> Navigation configuration - # Lists the formats that should be treated as navigational. Formats like - # :html should redirect to the sign in page when the user does not have - # access, but formats like :xml or :json, should return 401. - # - # If you have any extra navigational formats, like :iphone or :mobile, you - # should add them to the navigational formats lists. - # - # The "*/*" below is required to match Internet Explorer requests. - config.navigational_formats = [ "*/*", :html, :turbo_stream ] - - # The default HTTP method used to sign out a resource. Default is :delete. - config.sign_out_via = :delete - - # ==> OmniAuth - # Add a new OmniAuth provider. Check the wiki for more information on setting - # up on your models and hooks. - config.omniauth :github, - nil, # Set dynamically via setup - nil, # Set dynamically via setup - scope: "user:email,read:user", - setup: lambda { |env| - request = Rack::Request.new(env) - strategy = env["omniauth.strategy"] - - # In production, use domain-specific OAuth apps - # In development, always use whyruby credentials (localhost works with it) - domains = Rails.application.config.x.domains - if Rails.env.production? && request.host == domains.community - strategy.options[:client_id] = Rails.application.credentials.dig(:github, :rubycommunity, :client_id) - strategy.options[:client_secret] = Rails.application.credentials.dig(:github, :rubycommunity, :client_secret) - strategy.options[:callback_url] = "https://#{domains.community}/users/auth/github/callback" - else - strategy.options[:client_id] = Rails.application.credentials.dig(:github, :whyruby, :client_id) - strategy.options[:client_secret] = Rails.application.credentials.dig(:github, :whyruby, :client_secret) - strategy.options[:callback_url] = Rails.env.production? ? "https://#{domains.primary}/users/auth/github/callback" : nil - end - } - - # Fix for CSRF issues with OmniAuth - config.omniauth_path_prefix = "/users/auth" - - # ==> Warden configuration - # If you want to use other strategies, that are not supported by Devise, or - # change the failure app, you can configure them inside the config.warden block. - # - # config.warden do |manager| - # manager.intercept_401 = false - # manager.default_strategies(scope: :user).unshift :some_external_strategy - # end - - # ==> Mountable engine configurations - # When using Devise inside an engine, let's call it `MyEngine`, and this engine - # is mountable, there are some extra configurations to be taken into account. - # The following options are available, assuming the engine is mounted as: - # - # mount MyEngine, at: '/my_engine' - # - # The router that invoked `devise_for`, in the example above, would be: - # config.router_name = :my_engine - # - # When using OmniAuth, Devise cannot automatically set OmniAuth path, - # so you need to do it manually. For the users scope, it would be: - # config.omniauth_path_prefix = '/my_engine/users/auth' - - # ==> Hotwire/Turbo configuration - # When using Devise with Hotwire/Turbo, the http status for error responses - # and some redirects must match the following. The default in Devise for existing - # apps is `200 OK` and `302 Found` respectively, but new apps are generated with - # these new defaults that match Hotwire/Turbo behavior. - # Note: These might become the new default in future versions of Devise. - config.responder.error_status = :unprocessable_entity - config.responder.redirect_status = :see_other - - # ==> Configuration for :registerable - - # When set to false, does not sign a user in automatically after their password is - # changed. Defaults to true, so a user is signed in automatically after changing a password. - # config.sign_in_after_change_password = true -end diff --git a/config/initializers/fast_mcp.rb b/config/initializers/fast_mcp.rb new file mode 100644 index 0000000..f5e61f9 --- /dev/null +++ b/config/initializers/fast_mcp.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# FastMcp - Model Context Protocol for Rails +# +# Transport: Streamable HTTP (MCP spec 2025-03-26) +# - Tool calls via HTTP POST to /mcp/messages +# - Server notifications via SSE at /mcp/sse (optional) +# +# Authentication: +# - API key via x-api-key header for user authentication +# +# Testing with MCP Inspector: +# 1. Start server: bin/dev +# 2. Open MCP Inspector: npx @anthropic-ai/mcp-inspector +# 3. Select "Streamable HTTP" transport +# 4. URL: http://localhost:3000/mcp/messages +# 5. Add header: x-api-key: +# 6. Connect and test tools +# +# All tools inherit from ApplicationTool (ActionTool::Base) +# All resources inherit from ApplicationResource (ActionResource::Base) + +require "fast_mcp" + +# Patch fast-mcp to support Streamable HTTP transport. +# fast-mcp 1.6.0 sends all responses via SSE and returns empty HTTP bodies. +# This patch returns responses inline in the HTTP POST body, which is what +# MCP Inspector and modern MCP clients expect (Streamable HTTP pattern). +module StreamableHttpTransport + private + + def process_json_request_with_server(request, server) + body = request.body.read + @logger.debug("Request body: #{body}") + + headers = request.env.select { |k, _v| k.start_with?("HTTP_") } + .transform_keys { |k| k.sub("HTTP_", "").downcase.tr("_", "-") } + + # Capture the response instead of broadcasting via SSE. + # Temporarily replace transport's send_message to capture the response. + captured_response = nil + original_transport = server.transport + + capturing_transport = Object.new + capturing_transport.define_singleton_method(:send_message) { |msg| captured_response = msg } + server.transport = capturing_transport + + server.handle_request(body, headers: headers) + + server.transport = original_transport + + if captured_response + json_response = captured_response.is_a?(String) ? captured_response : JSON.generate(captured_response) + [ 200, { "Content-Type" => "application/json" }, [ json_response ] ] + else + # No response needed (e.g., notification acknowledgment) + [ 202, { "Content-Type" => "application/json" }, [ "" ] ] + end + end +end + +FastMcp::Transports::RackTransport.prepend(StreamableHttpTransport) + +FastMcp.mount_in_rails( + Rails.application, + name: Rails.application.class.module_parent_name.underscore.dasherize, + version: "1.0.0", + path_prefix: "/mcp", + messages_route: "messages", + sse_route: "sse" +) do |server| + Rails.application.config.after_initialize do + # Register all tool and resource descendants + server.register_tools(*ApplicationTool.descendants) + server.register_resources(*ApplicationResource.descendants) + end +end diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb index 7577476..5bd33b9 100644 --- a/config/initializers/geocoder.rb +++ b/config/initializers/geocoder.rb @@ -1,17 +1,9 @@ -# frozen_string_literal: true +mmdb_path = Rails.root.join("db", "GeoLite2-Country.mmdb") -# Geocoder is used only for IP geolocation (country detection for analytics) -# Location string geocoding uses Photon API directly (see LocationNormalizer) - -geoip_database = Rails.root.join("db", "GeoLite2-Country.mmdb") - -if File.exist?(geoip_database) - Geocoder.configure( - ip_lookup: :geoip2, - geoip2: { file: geoip_database }, - units: :km - ) -else - Rails.logger.info "GeoLite2 database not found at #{geoip_database}. IP geolocation disabled." - Geocoder.configure(ip_lookup: :test, units: :km) -end +Geocoder.configure( + if mmdb_path.exist? + { ip_lookup: :geoip2, geoip2: { file: mmdb_path.to_s } } + else + { ip_lookup: :test, test: [] } + end +) diff --git a/config/initializers/litestream.rb b/config/initializers/litestream.rb index aa2bac3..d70bcee 100644 --- a/config/initializers/litestream.rb +++ b/config/initializers/litestream.rb @@ -1,49 +1,11 @@ -# Use this hook to configure the litestream-ruby gem. -# All configuration options will be available as environment variables, e.g. -# config.replica_bucket becomes LITESTREAM_REPLICA_BUCKET -# This allows you to configure Litestream using Rails encrypted credentials, -# or some other mechanism where the values are only available at runtime. +# Litestream credentials are loaded from Setting via config/initializers/settings.rb +# +# Configure additional Litestream options below if needed. Rails.application.configure do - # Configure Litestream through environment variables. Use Rails encrypted credentials for secrets. - litestream_credentials = Rails.application.credentials.litestream - - config.litestream.replica_bucket = litestream_credentials&.replica_bucket - config.litestream.replica_key_id = litestream_credentials&.replica_key_id - config.litestream.replica_access_key = litestream_credentials&.replica_access_key - - # Replica-specific bucket location. This will be your bucket's URL without the `https://` prefix. - # For example, if you used DigitalOcean Spaces, your bucket URL could look like: - # - # https://myapp.fra1.digitaloceanspaces.com - # - # And so you should set your `replica_bucket` to: - # - # myapp.fra1.digitaloceanspaces.com - # - # config.litestream.replica_bucket = litestream_credentials&.replica_bucket - # - # Replica-specific authentication key. Litestream needs authentication credentials to access your storage provider bucket. - # config.litestream.replica_key_id = litestream_credentials&.replica_key_id - # - # Replica-specific secret key. Litestream needs authentication credentials to access your storage provider bucket. - # config.litestream.replica_access_key = litestream_credentials&.replica_access_key - # - # Replica-specific region. Set the bucket’s region. Only used for AWS S3 & Backblaze B2. + # Replica-specific region. Set the bucket's region. Only used for AWS S3 & Backblaze B2. # config.litestream.replica_region = "us-east-1" # # Replica-specific endpoint. Set the endpoint URL of the S3-compatible service. Only required for non-AWS services. # config.litestream.replica_endpoint = "endpoint.your-objectstorage.com" - - # Configure the default Litestream config path - # config.config_path = Rails.root.join("config", "litestream.yml") - - # Configure the Litestream dashboard - # - # Set the default base controller class - # config.litestream.base_controller_class = "MyApplicationController" - # - # Set authentication credentials for Litestream dashboard - # config.litestream.username = litestream_credentials&.username - # config.litestream.password = litestream_credentials&.password end diff --git a/config/initializers/markdown.rb b/config/initializers/markdown.rb new file mode 100644 index 0000000..093db55 --- /dev/null +++ b/config/initializers/markdown.rb @@ -0,0 +1 @@ +require "rouge/plugins/redcarpet" diff --git a/config/initializers/mobility.rb b/config/initializers/mobility.rb new file mode 100644 index 0000000..17934af --- /dev/null +++ b/config/initializers/mobility.rb @@ -0,0 +1,29 @@ +# Discover locales from config/locales directory (yml files and subdirectories) +locale_path = Rails.root.join("config/locales") +discovered_locales = Dir.children(locale_path).filter_map { |entry| + entry.delete_suffix(".yml").to_sym if entry.end_with?(".yml") || File.directory?(locale_path.join(entry)) +}.uniq + +# Register all discovered locales so Mobility accepts them +I18n.available_locales |= discovered_locales + +# Build fallbacks: every non-default locale falls back to the default locale +locale_fallbacks = (discovered_locales - [ I18n.default_locale ]).each_with_object({}) do |locale, hash| + hash[locale] = I18n.default_locale +end + +Mobility.configure do + plugins do + backend :key_value + + active_record + + reader + writer + locale_accessors + + query + + fallbacks(locale_fallbacks) + end +end diff --git a/config/initializers/nullitics.rb b/config/initializers/nullitics.rb new file mode 100644 index 0000000..1f48b74 --- /dev/null +++ b/config/initializers/nullitics.rb @@ -0,0 +1 @@ +Rails.configuration.x.nullitics = false diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index 52fcc53..944330c 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -1,8 +1,25 @@ Rails.application.config.middleware.use OmniAuth::Builder do - # This is needed to fix the "Authentication passthru" error - # The actual provider config is in devise.rb + provider :github, + nil, # Set dynamically via setup + nil, # Set dynamically via setup + scope: "user:email,read:user", + callback_path: "/auth/github/callback", + setup: lambda { |env| + request = Rack::Request.new(env) + strategy = env["omniauth.strategy"] + + domains = Rails.application.config.x.domains + if Rails.env.production? && request.host == domains.community + strategy.options[:client_id] = Setting.get(:github_rubycommunity_client_id) + strategy.options[:client_secret] = Setting.get(:github_rubycommunity_client_secret) + strategy.options[:callback_url] = "https://#{domains.community}/auth/github/callback" + else + strategy.options[:client_id] = Setting.get(:github_whyruby_client_id) + strategy.options[:client_secret] = Setting.get(:github_whyruby_client_secret) + strategy.options[:callback_url] = Rails.env.production? ? "https://#{domains.primary}/auth/github/callback" : nil + end + } end -# Fix for Omniauth POST requests OmniAuth.config.allowed_request_methods = [ :post, :get ] OmniAuth.config.silence_get_warning = true diff --git a/config/initializers/ruby_llm.rb b/config/initializers/ruby_llm.rb new file mode 100644 index 0000000..2a4691e --- /dev/null +++ b/config/initializers/ruby_llm.rb @@ -0,0 +1,5 @@ +RubyLLM.configure do |config| + # Initial default — overridden by Setting.reconfigure! after app loads + config.default_model = "gpt-4.1-nano" + config.use_new_acts_as = true +end diff --git a/config/initializers/settings.rb b/config/initializers/settings.rb new file mode 100644 index 0000000..5f3d352 --- /dev/null +++ b/config/initializers/settings.rb @@ -0,0 +1,3 @@ +Rails.application.config.after_initialize do + Setting.reconfigure! +end diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb new file mode 100644 index 0000000..8aa2408 --- /dev/null +++ b/config/initializers/stripe.rb @@ -0,0 +1 @@ +# Stripe API key is loaded from Setting via config/initializers/settings.rb diff --git a/config/locales/de.yml b/config/locales/de.yml new file mode 100644 index 0000000..42b1b1c --- /dev/null +++ b/config/locales/de.yml @@ -0,0 +1,9 @@ +de: + language_name: German + native_name: Deutsch + languages: + de: Deutsch + en: Englisch + es: Spanisch + fr: "Französisch" + ru: Russisch diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml deleted file mode 100644 index 260e1c4..0000000 --- a/config/locales/devise.en.yml +++ /dev/null @@ -1,65 +0,0 @@ -# Additional translations at https://github.com/heartcombo/devise/wiki/I18n - -en: - devise: - confirmations: - confirmed: "Your email address has been successfully confirmed." - send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." - send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." - failure: - already_authenticated: "You are already signed in." - inactive: "Your account is not activated yet." - invalid: "Invalid %{authentication_keys} or password." - locked: "Your account is locked." - last_attempt: "You have one more attempt before your account is locked." - not_found_in_database: "Invalid %{authentication_keys} or password." - timeout: "Your session expired. Please sign in again to continue." - unauthenticated: "You need to sign in or sign up before continuing." - unconfirmed: "You have to confirm your email address before continuing." - mailer: - confirmation_instructions: - subject: "Confirmation instructions" - reset_password_instructions: - subject: "Reset password instructions" - unlock_instructions: - subject: "Unlock instructions" - email_changed: - subject: "Email Changed" - password_change: - subject: "Password Changed" - omniauth_callbacks: - failure: "Could not authenticate you from %{kind} because \"%{reason}\"." - success: "Successfully authenticated from %{kind} account." - passwords: - no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." - send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." - send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." - updated: "Your password has been changed successfully. You are now signed in." - updated_not_active: "Your password has been changed successfully." - registrations: - destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." - signed_up: "Welcome! You have signed up successfully." - signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." - signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." - signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." - update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." - updated: "Your account has been updated successfully." - updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." - sessions: - signed_in: "Signed in successfully." - signed_out: "Signed out successfully." - already_signed_out: "Signed out successfully." - unlocks: - send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." - send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." - unlocked: "Your account has been unlocked successfully. Please sign in to continue." - errors: - messages: - already_confirmed: "was already confirmed, please try signing in" - confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" - expired: "has expired, please request a new one" - not_found: "not found" - not_locked: "was not locked" - not_saved: - one: "1 error prohibited this %{resource} from being saved:" - other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/locales/en.yml b/config/locales/en.yml index 626e269..42056c6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,397 +1,392 @@ -# Files in the config/locales directory are used for internationalization and -# are automatically loaded by Rails. If you want to use locales other than -# English, add the necessary files in this directory. -# -# To use the locales, use `I18n.t`: -# -# I18n.t "hello" -# -# In views, this is aliased to just `t`: -# -# <%= t("hello") %> -# -# To use a different locale, set it with `I18n.locale`: -# -# I18n.locale = :es -# -# This would use the information in config/locales/es.yml. -# -# To learn more about the API, please read the Rails Internationalization guide -# at https://guides.rubyonrails.org/i18n.html. -# -# Be aware that YAML interprets the following case-insensitive strings as -# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings -# must be quoted to be interpreted as strings. For example: -# -# en: -# "yes": yup -# enabled: "ON" - +--- en: - hello: "Hello world" - - countries: - AF: "Afghanistan" - AL: "Albania" - DZ: "Algeria" - AS: "American Samoa" - AD: "Andorra" - AO: "Angola" - AI: "Anguilla" - AQ: "Antarctica" - AG: "Antigua and Barbuda" - AR: "Argentina" - AM: "Armenia" - AW: "Aruba" - AU: "Australia" - AT: "Austria" - AZ: "Azerbaijan" - BS: "Bahamas" - BH: "Bahrain" - BD: "Bangladesh" - BB: "Barbados" - BY: "Belarus" - BE: "Belgium" - BZ: "Belize" - BJ: "Benin" - BM: "Bermuda" - BT: "Bhutan" - BO: "Bolivia" - BQ: "Bonaire, Sint Eustatius and Saba" - BA: "Bosnia and Herzegovina" - BW: "Botswana" - BV: "Bouvet Island" - BR: "Brazil" - IO: "British Indian Ocean Territory" - BN: "Brunei Darussalam" - BG: "Bulgaria" - BF: "Burkina Faso" - BI: "Burundi" - CV: "Cabo Verde" - KH: "Cambodia" - CM: "Cameroon" - CA: "Canada" - KY: "Cayman Islands" - CF: "Central African Republic" - TD: "Chad" - CL: "Chile" - CN: "China" - CX: "Christmas Island" - CC: "Cocos (Keeling) Islands" - CO: "Colombia" - KM: "Comoros" - CG: "Congo" - CD: "Democratic Republic of the Congo" - CK: "Cook Islands" - CR: "Costa Rica" - HR: "Croatia" - CU: "Cuba" - CW: "Curaçao" - CY: "Cyprus" - CZ: "Czech Republic" - DK: "Denmark" - DJ: "Djibouti" - DM: "Dominica" - DO: "Dominican Republic" - EC: "Ecuador" - EG: "Egypt" - SV: "El Salvador" - GQ: "Equatorial Guinea" - ER: "Eritrea" - EE: "Estonia" - SZ: "Eswatini" - ET: "Ethiopia" - FK: "Falkland Islands (Malvinas)" - FO: "Faroe Islands" - FJ: "Fiji" - FI: "Finland" - FR: "France" - GF: "French Guiana" - PF: "French Polynesia" - TF: "French Southern Territories" - GA: "Gabon" - GM: "Gambia" - GE: "Georgia" - DE: "Germany" - GH: "Ghana" - GI: "Gibraltar" - GR: "Greece" - GL: "Greenland" - GD: "Grenada" - GP: "Guadeloupe" - GU: "Guam" - GT: "Guatemala" - GG: "Guernsey" - GN: "Guinea" - GW: "Guinea-Bissau" - GY: "Guyana" - HT: "Haiti" - HM: "Heard Island and McDonald Islands" - VA: "Holy See (Vatican City State)" - HN: "Honduras" - HK: "Hong Kong" - HU: "Hungary" - IS: "Iceland" - IN: "India" - ID: "Indonesia" - IR: "Iran, Islamic Republic of" - IQ: "Iraq" - IE: "Ireland" - IM: "Isle of Man" - IL: "Israel" - IT: "Italy" - JM: "Jamaica" - JP: "Japan" - JE: "Jersey" - JO: "Jordan" - KZ: "Kazakhstan" - KE: "Kenya" - KI: "Kiribati" - KP: "Korea, Democratic People's Republic of" - KR: "Korea, Republic of" - KW: "Kuwait" - KG: "Kyrgyzstan" - LA: "Lao People's Democratic Republic" - LV: "Latvia" - LB: "Lebanon" - LS: "Lesotho" - LR: "Liberia" - LY: "Libya" - LI: "Liechtenstein" - LT: "Lithuania" - LU: "Luxembourg" - MO: "Macao" - MG: "Madagascar" - MW: "Malawi" - MY: "Malaysia" - MV: "Maldives" - ML: "Mali" - MT: "Malta" - MH: "Marshall Islands" - MQ: "Martinique" - MR: "Mauritania" - MU: "Mauritius" - YT: "Mayotte" - MX: "Mexico" - FM: "Micronesia (Federated States of)" - MD: "Moldova, Republic of" - MC: "Monaco" - MN: "Mongolia" - ME: "Montenegro" - MS: "Montserrat" - MA: "Morocco" - MZ: "Mozambique" - MM: "Myanmar" - NA: "Namibia" - NR: "Nauru" - NP: "Nepal" - NL: "Netherlands" - NC: "New Caledonia" - NZ: "New Zealand" - NI: "Nicaragua" - NE: "Niger" - NG: "Nigeria" - NU: "Niue" - NF: "Norfolk Island" - MK: "North Macedonia" - MP: "Northern Mariana Islands" - NO: "Norway" - OM: "Oman" - PK: "Pakistan" - PW: "Palau" - PS: "Palestine, State of" - PA: "Panama" - PG: "Papua New Guinea" - PY: "Paraguay" - PE: "Peru" - PH: "Philippines" - PN: "Pitcairn" - PL: "Poland" - PT: "Portugal" - PR: "Puerto Rico" - QA: "Qatar" - RE: "Réunion" - RO: "Romania" - RU: "Russian Federation" - RW: "Rwanda" - BL: "Saint Barthélemy" - SH: "Saint Helena, Ascension and Tristan da Cunha" - KN: "Saint Kitts and Nevis" - LC: "Saint Lucia" - MF: "Saint Martin (French part)" - PM: "Saint Pierre and Miquelon" - VC: "Saint Vincent and the Grenadines" - WS: "Samoa" - SM: "San Marino" - ST: "Sao Tome and Principe" - SA: "Saudi Arabia" - SN: "Senegal" - RS: "Serbia" - SC: "Seychelles" - SL: "Sierra Leone" - SG: "Singapore" - SX: "Sint Maarten (Dutch part)" - SK: "Slovakia" - SI: "Slovenia" - SB: "Solomon Islands" - SO: "Somalia" - ZA: "South Africa" - GS: "South Georgia and the South Sandwich Islands" - SS: "South Sudan" - ES: "Spain" - LK: "Sri Lanka" - SD: "Sudan" - SR: "Suriname" - SJ: "Svalbard and Jan Mayen" - SE: "Sweden" - CH: "Switzerland" - SY: "Syrian Arab Republic" - TW: "Taiwan, Province of China" - TJ: "Tajikistan" - TZ: "Tanzania, United Republic of" - TH: "Thailand" - TL: "Timor-Leste" - TG: "Togo" - TK: "Tokelau" - TO: "Tonga" - TT: "Trinidad and Tobago" - TN: "Tunisia" - TR: "Turkey" - TM: "Turkmenistan" - TC: "Turks and Caicos Islands" - TV: "Tuvalu" - UG: "Uganda" - UA: "Ukraine" - AE: "United Arab Emirates" - GB: "United Kingdom" - UM: "United States Minor Outlying Islands" - US: "United States of America" - UY: "Uruguay" - UZ: "Uzbekistan" - VU: "Vanuatu" - VE: "Venezuela (Bolivarian Republic of)" - VN: "Viet Nam" - VG: "Virgin Islands (British)" - VI: "Virgin Islands (U.S.)" - WF: "Wallis and Futuna" - EH: "Western Sahara" - YE: "Yemen" - ZM: "Zambia" - ZW: "Zimbabwe" - - # Shared values - defined here to be referenced elsewhere - shared: - site: - name: &site_name "WhyRuby.info" - app_name: &app_name "Why Ruby?" - apple_app_title: &apple_app_title "WhyRuby" - community: - title: &community_title "Community" - description: &community_description "Connect with %{count} passionate Ruby developers from around the world and explore their projects, posts, and stories" - description_static: &community_description_static "Connect with passionate Ruby developers from around the world and explore their projects, posts, and stories" - keywords: &community_keywords "ruby developers, ruby community, ruby programmers, rails developers" - keywords: - base: &keywords_base "Ruby, Ruby on Rails" - programming: &keywords_programming "programming, web development" - + app_name: Why Ruby? auth: - sign_in_with_github: "Sign in with GitHub" - + sign_in_with_github: Sign in with GitHub comments: - sign_in_to_comment: "Sign in to leave a comment." - - posts: - draft: "Draft" - form: - tags: - label: "Tags" - placeholder: "Type to search tags or create new ones (separate with commas)" - placeholder_with_tags: "Add more tags..." - help_text: "Press Enter or comma to add a tag. Start typing to see suggestions." - create_new: "Create new tag:" - + sign_in_to_comment: Sign in to leave a comment. + common: + back: Back + created: Created + default: Default + sign_out: Sign Out + untitled: Untitled + countries: + AD: Andorra + AE: United Arab Emirates + AF: Afghanistan + AG: Antigua and Barbuda + AI: Anguilla + AL: Albania + AM: Armenia + AO: Angola + AQ: Antarctica + AR: Argentina + AS: American Samoa + AT: Austria + AU: Australia + AW: Aruba + AZ: Azerbaijan + BA: Bosnia and Herzegovina + BB: Barbados + BD: Bangladesh + BE: Belgium + BF: Burkina Faso + BG: Bulgaria + BH: Bahrain + BI: Burundi + BJ: Benin + BL: Saint Barthélemy + BM: Bermuda + BN: Brunei Darussalam + BO: Bolivia + BQ: Bonaire, Sint Eustatius and Saba + BR: Brazil + BS: Bahamas + BT: Bhutan + BV: Bouvet Island + BW: Botswana + BY: Belarus + BZ: Belize + CA: Canada + CC: Cocos (Keeling) Islands + CD: Democratic Republic of the Congo + CF: Central African Republic + CG: Congo + CH: Switzerland + CK: Cook Islands + CL: Chile + CM: Cameroon + CN: China + CO: Colombia + CR: Costa Rica + CU: Cuba + CV: Cabo Verde + CW: Curaçao + CX: Christmas Island + CY: Cyprus + CZ: Czech Republic + DE: Germany + DJ: Djibouti + DK: Denmark + DM: Dominica + DO: Dominican Republic + DZ: Algeria + EC: Ecuador + EE: Estonia + EG: Egypt + EH: Western Sahara + ER: Eritrea + ES: Spain + ET: Ethiopia + FI: Finland + FJ: Fiji + FK: Falkland Islands (Malvinas) + FM: Micronesia (Federated States of) + FO: Faroe Islands + FR: France + GA: Gabon + GB: United Kingdom + GD: Grenada + GE: Georgia + GF: French Guiana + GG: Guernsey + GH: Ghana + GI: Gibraltar + GL: Greenland + GM: Gambia + GN: Guinea + GP: Guadeloupe + GQ: Equatorial Guinea + GR: Greece + GS: South Georgia and the South Sandwich Islands + GT: Guatemala + GU: Guam + GW: Guinea-Bissau + GY: Guyana + HK: Hong Kong + HM: Heard Island and McDonald Islands + HN: Honduras + HR: Croatia + HT: Haiti + HU: Hungary + ID: Indonesia + IE: Ireland + IL: Israel + IM: Isle of Man + IN: India + IO: British Indian Ocean Territory + IQ: Iraq + IR: Iran, Islamic Republic of + IS: Iceland + IT: Italy + JE: Jersey + JM: Jamaica + JO: Jordan + JP: Japan + KE: Kenya + KG: Kyrgyzstan + KH: Cambodia + KI: Kiribati + KM: Comoros + KN: Saint Kitts and Nevis + KP: Korea, Democratic People's Republic of + KR: Korea, Republic of + KW: Kuwait + KY: Cayman Islands + KZ: Kazakhstan + LA: Lao People's Democratic Republic + LB: Lebanon + LC: Saint Lucia + LI: Liechtenstein + LK: Sri Lanka + LR: Liberia + LS: Lesotho + LT: Lithuania + LU: Luxembourg + LV: Latvia + LY: Libya + MA: Morocco + MC: Monaco + MD: Moldova, Republic of + ME: Montenegro + MF: Saint Martin (French part) + MG: Madagascar + MH: Marshall Islands + MK: North Macedonia + ML: Mali + MM: Myanmar + MN: Mongolia + MO: Macao + MP: Northern Mariana Islands + MQ: Martinique + MR: Mauritania + MS: Montserrat + MT: Malta + MU: Mauritius + MV: Maldives + MW: Malawi + MX: Mexico + MY: Malaysia + MZ: Mozambique + NA: Namibia + NC: New Caledonia + NE: Niger + NF: Norfolk Island + NG: Nigeria + NI: Nicaragua + NL: Netherlands + NP: Nepal + NR: Nauru + NU: Niue + NZ: New Zealand + OM: Oman + PA: Panama + PE: Peru + PF: French Polynesia + PG: Papua New Guinea + PH: Philippines + PK: Pakistan + PL: Poland + PM: Saint Pierre and Miquelon + PN: Pitcairn + PR: Puerto Rico + PS: Palestine, State of + PT: Portugal + PW: Palau + PY: Paraguay + QA: Qatar + RE: Réunion + RO: Romania + RS: Serbia + RU: Russian Federation + RW: Rwanda + SA: Saudi Arabia + SB: Solomon Islands + SC: Seychelles + SD: Sudan + SE: Sweden + SG: Singapore + SH: Saint Helena, Ascension and Tristan da Cunha + SI: Slovenia + SJ: Svalbard and Jan Mayen + SK: Slovakia + SL: Sierra Leone + SM: San Marino + SN: Senegal + SO: Somalia + SR: Suriname + SS: South Sudan + ST: Sao Tome and Principe + SV: El Salvador + SX: Sint Maarten (Dutch part) + SY: Syrian Arab Republic + SZ: Eswatini + TC: Turks and Caicos Islands + TD: Chad + TF: French Southern Territories + TG: Togo + TH: Thailand + TJ: Tajikistan + TK: Tokelau + TL: Timor-Leste + TM: Turkmenistan + TN: Tunisia + TO: Tonga + TR: Turkey + TT: Trinidad and Tobago + TV: Tuvalu + TW: Taiwan, Province of China + TZ: Tanzania, United Republic of + UA: Ukraine + UG: Uganda + UM: United States Minor Outlying Islands + US: United States of America + UY: Uruguay + UZ: Uzbekistan + VA: Holy See (Vatican City State) + VC: Saint Vincent and the Grenadines + VE: Venezuela (Bolivarian Republic of) + VG: Virgin Islands (British) + VI: Virgin Islands (U.S.) + VN: Viet Nam + VU: Vanuatu + WF: Wallis and Futuna + WS: Samoa + YE: Yemen + YT: Mayotte + ZA: South Africa + ZM: Zambia + ZW: Zimbabwe + 'false': Norway + hello: Hello world + language_name: English + messages: + count: Messages meta: + categories: + show: + keywords: "%{category_name}, Ruby, Ruby on Rails, programming, web development" + summary: Browse all Ruby %{category_name_lower} posts, articles, and resources + shared by the WhyRuby community. + title: "%{category_name}" community: - site_name: "Ruby Community" + site_name: Ruby Community default: - title: "Why Ruby?" - summary: "Join the Ruby community! Discover why developers love Ruby, share your experiences, and explore resources for learning and mastering Ruby and Rails." - keywords: "Ruby, Ruby on Rails, Rails, programming, web development, Ruby community, Ruby tutorials, Ruby resources, Ruby advocacy, why Ruby" - author: "Yuri Sidorov" - site: - name: *site_name - application_name: *app_name - apple_app_title: *apple_app_title - social: - twitter_site: "@yurisidorov" - twitter_creator: "@yurisidorov" + author: Yuri Sidorov + keywords: Ruby, Ruby on Rails, Rails, programming, web development, Ruby community, + Ruby tutorials, Ruby resources, Ruby advocacy, why Ruby + summary: Join the Ruby community! Discover why developers love Ruby, share your + experiences, and explore resources for learning and mastering Ruby and Rails. + title: Why Ruby? posts: show: - title: "%{post_title}" keywords: "%{categories_and_tags}, Ruby, Rails" - categories: - show: - title: "%{category_name}" - summary: "Browse all Ruby %{category_name_lower} posts, articles, and resources shared by the WhyRuby community." - keywords: "%{category_name}, Ruby, Ruby on Rails, programming, web development" + title: "%{post_title}" + site: + apple_app_title: WhyRuby + application_name: Why Ruby? + name: WhyRuby.info + social: + twitter_creator: "@yurisidorov" + twitter_site: "@yurisidorov" tags: show: - title: "#%{tag_name}" - summary: "Explore Ruby posts and resources tagged with #%{tag_name} on WhyRuby.info" keywords: "%{tag_name}, Ruby, Ruby on Rails, programming, tags" + summary: 'Explore Ruby posts and resources tagged with #%{tag_name} on WhyRuby.info' + title: "#%{tag_name}" users: index: - title: *community_title - summary: *community_description_static - keywords: *community_keywords + keywords: ruby developers, ruby community, ruby programmers, rails developers + summary: Connect with passionate Ruby developers from around the world and + explore their projects, posts, and stories + title: Community show: - title: "%{display_name} (@%{username})" - summary_with_bio: "%{bio}" - summary_without_bio: "View %{display_name}'s profile and contributions to the Ruby community on RubyCommunity.org" keywords: "%{username}, Ruby developer, Ruby community, profile" - + summary_with_bio: "%{bio}" + summary_without_bio: View %{display_name}'s profile and contributions to the + Ruby community on RubyCommunity.org + title: "%{display_name} (@%{username})" + native_name: English + og_image: + description: Discover why Ruby continues to be a beloved language for developers + worldwide + tagline: Why Ruby? — Ruby advocacy community + posts: + draft: Draft + form: + tags: + create_new: 'Create new tag:' + help_text: Press Enter or comma to add a tag. Start typing to see suggestions. + label: Tags + placeholder: Type to search tags or create new ones (separate with commas) + placeholder_with_tags: Add more tags... + shared: + community: + description: Connect with %{count} passionate Ruby developers from around the + world and explore their projects, posts, and stories + description_static: Connect with passionate Ruby developers from around the + world and explore their projects, posts, and stories + keywords: ruby developers, ruby community, ruby programmers, rails developers + title: Community + keywords: + base: Ruby, Ruby on Rails + programming: programming, web development + site: + app_name: Why Ruby? + apple_app_title: WhyRuby + name: WhyRuby.info users: index: - title: *community_title - description: *community_description - join: "Join" - join_suffix: "%{count} passionate Ruby developers from around the world and explore their projects, posts, and stories" + description: Connect with %{count} passionate Ruby developers from around the + world and explore their projects, posts, and stories developer_count: - one: "developer" - other: "developers" + one: developer + other: developers + invite_friends: Invite your Ruby friends to join → + join: Join + join_community: Sign in with GitHub to appear on this page → + join_suffix: "%{count} passionate Ruby developers from around the world and + explore their projects, posts, and stories" + keywords: ruby developers, ruby community, ruby programmers, rails developers passionate_verb: - one: "is" - other: "are" - keywords: *community_keywords - invite_friends: "Invite your Ruby friends to join →" - join_community: "Sign in with GitHub to appear on this page →" - sort_label: "Sort" + one: is + other: are sort: - top: "Top contributors" - top_reverse: "Least contributors" - trending: "Trending today" - trending_reverse: "Least trending" - new: "Newcomers" - new_reverse: "Old members" - projects: "Top projects" - projects_reverse: "Fewest projects" - posts: "Top posts" - posts_reverse: "Fewest posts" - comments: "Top comments" - comments_reverse: "Fewest comments" - stars: "Top stars" - stars_reverse: "Fewest stars" - az: "A–Z by name" - az_reverse: "Z–A by name" - show: - view_all_repositories: "View all Ruby repositories on GitHub →" - no_ruby_projects: "No Ruby projects found on GitHub." - view_github_profile: "View GitHub profile →" + az: A–Z by name + az_reverse: Z–A by name + comments: Top comments + comments_reverse: Fewest comments + new: Newcomers + new_reverse: Old members + posts: Top posts + posts_reverse: Fewest posts + projects: Top projects + projects_reverse: Fewest projects + stars: Top stars + stars_reverse: Fewest stars + top: Top contributors + top_reverse: Least contributors + trending: Trending today + trending_reverse: Least trending + sort_label: Sort + title: Community repository_tile: - no_description: "No description provided" forks: - zero: "No forks" - one: "1 fork" + one: 1 fork other: "%{count} forks" + zero: No forks + last_commit: Last commit %{time_ago} ago + no_description: No description provided size: "%{size}" - last_commit: "Last commit %{time_ago} ago" \ No newline at end of file + show: + no_ruby_projects: No Ruby projects found on GitHub. + view_all_repositories: View all Ruby repositories on GitHub → + view_github_profile: View GitHub profile → + views: + pagination: + first: "« First" + last: Last » + next: Next › + previous: "‹ Prev" + truncate: "…" diff --git a/config/locales/en/controllers.yml b/config/locales/en/controllers.yml new file mode 100644 index 0000000..19be44d --- /dev/null +++ b/config/locales/en/controllers.yml @@ -0,0 +1,92 @@ +--- +en: + controllers: + admins: + sessions: + create: + alert: Admin not found. Only existing admins can log in. + notice: Check your email for a magic link! + destroy: + notice: Signed out successfully + rate_limit: + long: Too many attempts. Try again later. + short: Too many attempts. Please wait. + verify: Too many verification attempts. + verify: + alert: Invalid or expired magic link + notice: Welcome back, admin! + application: + admin_required: Admin access required + subscription_required: An active subscription is required to access this feature + team_not_found: Team not found or you don't have access + articles: + create: + notice: Article created + destroy: + notice: Article deleted + update: + notice: Article updated + madmin: + mail: + update: + notice: Mail settings saved successfully + prices: + sync: + notice: Prices synced from Stripe + providers: + update: + notice: Provider credentials saved successfully + settings: + ai_models: + update: + notice: AI model settings saved successfully + update: + notice: Settings saved successfully + models: + refreshes: + create: + notice: Models refreshed successfully + onboardings: + update: + notice: Welcome, %{name}! + profiles: + update: + notice: Profile updated + sessions: + create: + notice: Check your email for a magic link! + destroy: + notice: Signed out successfully + rate_limit: + long: Too many attempts. Try again later. + short: Too many attempts. Please wait. + verify: Too many verification attempts. + verify: + alert: Invalid or expired magic link + notice: Welcome back, %{name}! + teams: + create: + notice: Team created successfully + languages: + create: + notice: "%{language} added. Existing content is being translated." + destroy: + cannot_remove_last: At least one language is required + notice: "%{language} removed from team languages" + members: + already_member: This user is already a member of the team + cannot_remove_last_owner: Cannot remove the last owner + create: + notice: Invitation sent to %{email} + destroy: + removed: "%{name} has been removed from the team" + settings: + regenerate_api_key: + notice: API key regenerated successfully + update: + notice: Team settings updated + subscription_cancellations: + create: + notice: Your subscription will be canceled at the end of the billing period + destroy: + notice: Your subscription has been resumed diff --git a/config/locales/en/mailers.yml b/config/locales/en/mailers.yml new file mode 100644 index 0000000..a357df8 --- /dev/null +++ b/config/locales/en/mailers.yml @@ -0,0 +1,32 @@ +--- +en: + admin_mailer: + magic_link: + button: Admin Sign In + expiry: This link will expire in 15 minutes. + greeting: Hi Admin, + heading: Admin Sign In + ignore: If you didn't request this, please contact another administrator immediately. + instruction: 'Click the button below to sign in to the admin panel:' + link_instruction: 'Or copy and paste this link into your browser:' + subject: Your admin magic link to sign in + user_mailer: + magic_link: + button: Sign In + expiry: This link will expire in 15 minutes. + greeting: Hi %{name}, + heading: Sign in to your account + ignore: If you didn't request this, you can safely ignore this email. + instruction: 'Click the button below to sign in to your account:' + link_instruction: 'Or copy and paste this link into your browser:' + subject: Your magic link to sign in + team_invitation: + button: Join Team + expiry: This link will expire in 7 days. + greeting: Hi %{name}, + heading: You've been invited to join %{team_name} + ignore: If you didn't expect this invitation, you can safely ignore this email. + instruction: "%{inviter_name} has invited you to join %{team_name}. Click the + button below to accept:" + link_instruction: 'Or copy and paste this link into your browser:' + subject: You've been invited to join %{team_name} diff --git a/config/locales/en/views/admins/sessions.yml b/config/locales/en/views/admins/sessions.yml new file mode 100644 index 0000000..67ddf45 --- /dev/null +++ b/config/locales/en/views/admins/sessions.yml @@ -0,0 +1,15 @@ +--- +en: + admins: + sessions: + new: + email_help: Only existing admins can sign in. You'll receive a magic link + via email. + email_label: Admin email address + email_placeholder: admin@example.com + heading: Admin Access + restricted_area: Restricted Area + restricted_message: This is the admin login. If you need admin access, contact + an existing administrator. + subheading: Sign in with your admin magic link + submit: Send Admin Magic Link diff --git a/config/locales/en/views/articles.yml b/config/locales/en/views/articles.yml new file mode 100644 index 0000000..c6369ea --- /dev/null +++ b/config/locales/en/views/articles.yml @@ -0,0 +1,20 @@ +--- +en: + articles: + edit: + heading: Edit Article + form: + body_label: Body + body_placeholder: Write your article content + title_label: Title + title_placeholder: Enter article title + index: + empty: No articles yet. Create your first article. + heading: Articles + new_article: New Article + new: + heading: New Article + show: + delete: Delete + delete_confirm: Are you sure you want to delete this article? + edit: Edit diff --git a/config/locales/en/views/billing.yml b/config/locales/en/views/billing.yml new file mode 100644 index 0000000..e06a73b --- /dev/null +++ b/config/locales/en/views/billing.yml @@ -0,0 +1,26 @@ +--- +en: + teams: + billing: + show: + cancel_confirm: Are you sure you want to cancel your subscription? You will + retain access until the end of your billing period. + cancel_subscription: Cancel Subscription + cancellation_notice: Your subscription will end on %{date} + cancellation_notice_label: Cancellation Pending + days_remaining: + one: "%{count} day remaining" + other: "%{count} days remaining" + heading: Billing + manage_in_stripe: Manage in Stripe + period_ends_label: Current Period Ends + resume_subscription: Resume Subscription + status: + active: Active + canceling: Canceling + inactive: Inactive + past_due: Past Due + trialing: Free Trial + status_label: Subscription Status + trial_ends_label: Trial Ends + view_plans: View Plans diff --git a/config/locales/en/views/chats.yml b/config/locales/en/views/chats.yml new file mode 100644 index 0000000..22b5bbf --- /dev/null +++ b/config/locales/en/views/chats.yml @@ -0,0 +1,25 @@ +--- +en: + chats: + form: + default_model: Default (%{name}) + default_model_not_set: Default (Not set) + prompt_placeholder: What would you like to discuss? + index: + empty: + cta: Start Your First Chat + description: Start a conversation with an AI assistant + heading: No chats yet + heading: Your Chats + new_chat: New Chat + no_messages: No messages yet + subheading: Continue a conversation or start a new one + time_ago: "%{time} ago" + title: Chats + new: + back: Back to chats + heading: Start a New Chat + subheading: Choose a model and begin your conversation + title: New chat + show: + title: Chat diff --git a/config/locales/en/views/languages.yml b/config/locales/en/views/languages.yml new file mode 100644 index 0000000..d2b6282 --- /dev/null +++ b/config/locales/en/views/languages.yml @@ -0,0 +1,14 @@ +--- +en: + teams: + languages: + index: + active_description: Content will be automatically translated into these languages. + active_heading: Active Languages + add_button: Add Language + add_description: Add a language to enable automatic translation of content. + add_heading: Add Language + heading: Languages + remove_confirm: Remove %{language} from team languages? + required: Required + select_language: Select a language... diff --git a/config/locales/en/views/madmin/mail.yml b/config/locales/en/views/madmin/mail.yml new file mode 100644 index 0000000..7cd84c7 --- /dev/null +++ b/config/locales/en/views/madmin/mail.yml @@ -0,0 +1,26 @@ +--- +en: + madmin: + mail: + edit: + cancel: Cancel + general: General + heading: Edit Mail Settings + key_placeholder: Enter key... + mail_from: From Address + save: Save Settings + smtp: SMTP + smtp_address: Server Address + smtp_password: Password + smtp_username: Username + smtp_username_placeholder: your-username + show: + edit_button: Edit Settings + general: General + heading: Mail Settings + mail_from: From Address + not_configured: Not configured + smtp: SMTP + smtp_address: Server Address + smtp_password: Password + smtp_username: Username diff --git a/config/locales/en/views/madmin/prices.yml b/config/locales/en/views/madmin/prices.yml new file mode 100644 index 0000000..0068a2f --- /dev/null +++ b/config/locales/en/views/madmin/prices.yml @@ -0,0 +1,15 @@ +--- +en: + madmin: + prices: + show: + heading: Stripe Prices + no_prices: No prices found. Create products in Stripe Dashboard, then sync. + price_amount: Amount + price_id: Price ID + price_interval: Interval + price_product: Product + stripe_not_configured: Stripe is not configured + stripe_not_configured_hint: Add your Stripe API keys in API Keys settings + to manage prices. + sync_prices: Sync Prices diff --git a/config/locales/en/views/madmin/providers.yml b/config/locales/en/views/madmin/providers.yml new file mode 100644 index 0000000..0a73079 --- /dev/null +++ b/config/locales/en/views/madmin/providers.yml @@ -0,0 +1,36 @@ +--- +en: + madmin: + providers: + index: + description: Configure API keys for AI providers. New providers appear automatically + when supported by RubyLLM. + heading: AI Providers + key_placeholder: Enter key... + placeholders: + api_base: https://api.example.com + location: us-central1 + region: us-east-1 + providers: + anthropic: Anthropic + bedrock: Amazon Bedrock + deepseek: DeepSeek + gemini: Google Gemini + gpustack: GPUStack + mistral: Mistral + ollama: Ollama + openai: OpenAI + openrouter: OpenRouter + perplexity: Perplexity + vertexai: Google Vertex AI + xai: xAI + save: Save Providers + settings: + api_base: Base URL + api_key: API Key + location: Location + organization_id: Organization ID + project_id: Project ID + region: Region + secret_key: Secret Key + session_token: Session Token diff --git a/config/locales/en/views/madmin/settings.yml b/config/locales/en/views/madmin/settings.yml new file mode 100644 index 0000000..7720680 --- /dev/null +++ b/config/locales/en/views/madmin/settings.yml @@ -0,0 +1,72 @@ +--- +en: + madmin: + settings: + ai_models: + edit: + ai_models_hint: Per-task models override the default. Leave blank to use + the default (%{default_model}). + cancel: Cancel + default_ai_model: Default Model + default_ai_model_hint: Fallback model for all AI tasks when no per-task + model is set. + heading: Edit AI Models + save: Save AI Models + summary_model: Summary Model + testimonial_model: Testimonial Model + translation_model: Translation Model + uses_default: Uses default + validation_model: Validation Model + show: + default_ai_model: Default Model + edit_button: Edit AI Models + heading: AI Models + summary_model: Summary Model + testimonial_model: Testimonial Model + translation_model: Translation Model + uses_default: Uses default + validation_model: Validation Model + edit: + cancel: Cancel + github: GitHub + github_api_token: API Token + github_api_token_hint: Used for batch syncing GitHub profile data and repositories. + github_rubycommunity_client_id: RubyCommunity.org Client ID + github_rubycommunity_client_secret: RubyCommunity.org Client Secret + github_whyruby_client_id: WhyRuby.info Client ID + github_whyruby_client_secret: WhyRuby.info Client Secret + heading: Edit Settings + key_placeholder: Enter key... + litestream: Litestream + litestream_replica_access_key: Secret Access Key + litestream_replica_bucket: Replica Bucket + litestream_replica_key_id: Access Key ID + litestream_restart_note: Changes to Litestream settings require a server restart. + save: Save Settings + stripe: Stripe + stripe_publishable_key: Publishable Key + stripe_secret_key: Secret Key + stripe_webhook_secret: Webhook Secret + trial_days: Free Trial Days + trial_days_hint: Number of days new teams get as a free trial. Set to 0 to + disable. + show: + edit_button: Edit Settings + github: GitHub + github_api_token: API Token + github_rubycommunity_client_id: RubyCommunity.org Client ID + github_rubycommunity_client_secret: RubyCommunity.org Client Secret + github_whyruby_client_id: WhyRuby.info Client ID + github_whyruby_client_secret: WhyRuby.info Client Secret + heading: Settings + litestream: Litestream + litestream_replica_access_key: Secret Access Key + litestream_replica_bucket: Replica Bucket + litestream_replica_key_id: Access Key ID + litestream_restart_note: Changes to Litestream settings require a server restart. + not_configured: Not configured + stripe: Stripe + stripe_publishable_key: Publishable Key + stripe_secret_key: Secret Key + stripe_webhook_secret: Webhook Secret + trial_days: Free Trial Days diff --git a/config/locales/en/views/messages.yml b/config/locales/en/views/messages.yml new file mode 100644 index 0000000..b35a362 --- /dev/null +++ b/config/locales/en/views/messages.yml @@ -0,0 +1,5 @@ +--- +en: + messages: + form: + placeholder: Reply... diff --git a/config/locales/en/views/models.yml b/config/locales/en/views/models.yml new file mode 100644 index 0000000..213681f --- /dev/null +++ b/config/locales/en/views/models.yml @@ -0,0 +1,28 @@ +--- +en: + models: + index: + back: Back to chats + heading: Models + refresh: Refresh Models + table: + context_window: Context Window + model: Model + pricing: "$/1M tokens (In/Out)" + provider: Provider + title: Models + model: + show: Show + start_chat: Start chat + show: + all_models: All models + back: Back to chats + capabilities: Capabilities + context_window: Context Window + context_window_value: "%{count} tokens" + id: ID + max_output: Max Output + max_output_value: "%{count} tokens" + model: Model + provider: Provider + start_chat: Start chat with this model diff --git a/config/locales/en/views/onboardings.yml b/config/locales/en/views/onboardings.yml new file mode 100644 index 0000000..73f772b --- /dev/null +++ b/config/locales/en/views/onboardings.yml @@ -0,0 +1,13 @@ +--- +en: + onboardings: + show: + heading: Welcome! Let's get started + name_label: What's your name? + name_placeholder: Enter your name + subheading: Just a couple of things before we begin. + submit: Continue + team_name_help: You can change this later in team settings. + team_name_label: Name your team + team_name_placeholder: My Team + title: Get Started diff --git a/config/locales/en/views/pricing.yml b/config/locales/en/views/pricing.yml new file mode 100644 index 0000000..20daa41 --- /dev/null +++ b/config/locales/en/views/pricing.yml @@ -0,0 +1,17 @@ +--- +en: + teams: + pricing: + show: + already_subscribed: Your team already has an active subscription. + description: Choose a plan that works for your team + heading: Plans & Pricing + manage_billing: Manage Billing + month: month + monthly: Monthly + subscribe: Subscribe + trial_active: + one: Your free trial ends in %{count} day. Subscribe to keep access. + other: Your free trial ends in %{count} days. Subscribe to keep access. + year: year + yearly: Yearly diff --git a/config/locales/en/views/profiles.yml b/config/locales/en/views/profiles.yml new file mode 100644 index 0000000..a2462ca --- /dev/null +++ b/config/locales/en/views/profiles.yml @@ -0,0 +1,21 @@ +--- +en: + profiles: + edit: + avatar_label: Avatar + heading: Edit Profile + locale_auto: Auto-detect + locale_help: Sets your UI language and the source language for translations + locale_label: Language + name_label: Name + name_placeholder: Enter your name + submit: Save Changes + show: + edit_button: Edit + email: Email + heading: Profile + language: Language + locale_auto: Auto-detect + member_since: Member since + name: Name + title: Profile diff --git a/config/locales/en/views/sessions.yml b/config/locales/en/views/sessions.yml new file mode 100644 index 0000000..4329dfa --- /dev/null +++ b/config/locales/en/views/sessions.yml @@ -0,0 +1,11 @@ +--- +en: + sessions: + new: + email_help: We'll send you a magic link to sign in. If you don't have an account, + one will be created automatically. + email_label: Email address + email_placeholder: you@example.com + heading: Welcome + subheading: Sign in with a magic link sent to your email + submit: Send Magic Link diff --git a/config/locales/en/views/shared.yml b/config/locales/en/views/shared.yml new file mode 100644 index 0000000..2a5a4dc --- /dev/null +++ b/config/locales/en/views/shared.yml @@ -0,0 +1,17 @@ +--- +en: + shared: + image_upload: + drop_image: Drop image + uploading: Uploading… + sidebar: + articles: Articles + billing: Billing + chats: Chats + collapse: Collapse sidebar + home: Home + languages: Languages + members: Members + new_chat: New Chat + settings: Settings + user: User diff --git a/config/locales/en/views/teams.yml b/config/locales/en/views/teams.yml new file mode 100644 index 0000000..af226b2 --- /dev/null +++ b/config/locales/en/views/teams.yml @@ -0,0 +1,61 @@ +--- +en: + teams: + index: + create_team: Create New Team + heading: Select a Team + members: + index: + admin: Admin + heading: Team Members + invite_button: Invite Member + invited_by: Invited by + member: Member + owner: Owner + remove: Remove + role: Role + new: + description: We'll send them an email invitation to join your team. + email_label: Email Address + email_placeholder: colleague@example.com + heading: Invite Team Member + submit: Send Invitation + show: + admin: Admin + back: Back to members + email: Email + heading: Member Profile + invited_by: Invited by + joined: Joined + member: Member + name: Name + owner: Owner + role: Role + title: "%{name}" + new: + heading: Create a New Team + name_label: Team Name + name_placeholder: Enter team name + submit: Create Team + settings: + edit: + back: "← Back to settings" + heading: Edit Team Settings + logo_label: Logo + name_available: Name is available + name_label: Team Name + name_placeholder: Enter team name + name_taken: Name is already taken + submit: Save Changes + show: + api_key_description: Use this API key to authenticate MCP tool requests for + this team. + api_key_heading: API Key + edit_button: Edit + heading: Team Settings + logo_label: Logo + name_label: Team Name + no_logo: No logo uploaded + regenerate_api_key: Regenerate + regenerate_confirm: Are you sure? This will invalidate the current API key. + slug_label: Team URL diff --git a/config/locales/es.yml b/config/locales/es.yml new file mode 100644 index 0000000..b9d1e0d --- /dev/null +++ b/config/locales/es.yml @@ -0,0 +1,9 @@ +es: + language_name: Spanish + native_name: "Español" + languages: + de: "Alemán" + en: "Inglés" + es: "Español" + fr: "Francés" + ru: Ruso diff --git a/config/locales/fr.yml b/config/locales/fr.yml new file mode 100644 index 0000000..493cf15 --- /dev/null +++ b/config/locales/fr.yml @@ -0,0 +1,9 @@ +fr: + language_name: French + native_name: "Français" + languages: + de: Allemand + en: Anglais + es: Espagnol + fr: "Français" + ru: Russe diff --git a/config/locales/ru.yml b/config/locales/ru.yml new file mode 100644 index 0000000..3c8260b --- /dev/null +++ b/config/locales/ru.yml @@ -0,0 +1,23 @@ +--- +ru: + language_name: Russian + native_name: "Русский" + languages: + de: Немецкий + en: Английский + es: Испанский + fr: Французский + ru: Русский + app_name: Template + common: + back: Назад + created: Создано + default: По умолчанию + loading: "Загрузка..." + sign_out: Выйти + untitled: Без названия + messages: + count: Сообщения + og_image: + description: "Быстрая разработка на Rails 8, Hotwire, SQLite и AI-чат." + tagline: "AI-шаблон на Rails 8" diff --git a/config/locales/ru/controllers.yml b/config/locales/ru/controllers.yml new file mode 100644 index 0000000..93d1733 --- /dev/null +++ b/config/locales/ru/controllers.yml @@ -0,0 +1,92 @@ +--- +ru: + controllers: + admins: + sessions: + create: + alert: Администратор не найден. Вход только для существующих администраторов. + notice: Проверьте почту — мы отправили вам ссылку для входа! + destroy: + notice: Вы успешно вышли + rate_limit: + long: Слишком много попыток. Попробуйте позже. + short: Слишком много попыток. Подождите. + verify: Слишком много попыток подтверждения. + verify: + alert: Недействительная или просроченная ссылка + notice: "С возвращением, администратор!" + application: + admin_required: Требуется доступ администратора + authenticate_admin: Войдите как администратор + authenticate_user: Пожалуйста, войдите в систему + chats_disabled: Чаты сейчас недоступны + subscription_required: Для доступа к этой функции необходима активная подписка + team_not_found: Команда не найдена или у вас нет доступа + articles: + create: + notice: Статья создана + destroy: + notice: Статья удалена + update: + notice: Статья обновлена + madmin: + mail: + update: + notice: Настройки почты сохранены + prices: + sync: + notice: Цены синхронизированы из Stripe + providers: + update: + notice: Учётные данные провайдера сохранены + settings: + update: + notice: Настройки сохранены + models: + refreshes: + create: + notice: Модели обновлены + onboardings: + update: + notice: "Добро пожаловать, %{name}!" + profiles: + update: + notice: Профиль обновлён + sessions: + create: + notice: Проверьте почту — мы отправили вам ссылку для входа! + destroy: + notice: Вы успешно вышли + rate_limit: + long: Слишком много попыток. Попробуйте позже. + short: Слишком много попыток. Подождите. + verify: Слишком много попыток подтверждения. + verify: + alert: Недействительная или просроченная ссылка + notice: "С возвращением, %{name}!" + teams: + create: + notice: Команда создана + languages: + create: + notice: "%{language} добавлен. Существующий контент переводится." + destroy: + cannot_remove_last: Необходим хотя бы один язык + notice: "%{language} удалён из языков команды" + members: + already_member: Этот пользователь уже состоит в команде + cannot_remove_last_owner: Нельзя удалить последнего владельца + create: + notice: "Приглашение отправлено на %{email}" + destroy: + removed: "%{name} удалён из команды" + settings: + regenerate_api_key: + notice: API-ключ перегенерирован + update: + notice: Настройки команды обновлены + subscription_cancellations: + create: + notice: Ваша подписка будет отменена в конце расчётного периода + destroy: + notice: Ваша подписка возобновлена diff --git a/config/locales/ru/mailers.yml b/config/locales/ru/mailers.yml new file mode 100644 index 0000000..de1d1ea --- /dev/null +++ b/config/locales/ru/mailers.yml @@ -0,0 +1,31 @@ +--- +ru: + admin_mailer: + magic_link: + button: Войти как администратор + expiry: Ссылка действительна 15 минут. + greeting: "Здравствуйте," + heading: Вход для администратора + ignore: Если вы не запрашивали это, немедленно свяжитесь с другим администратором. + instruction: "Нажмите на кнопку ниже, чтобы войти в панель администратора:" + link_instruction: "Или скопируйте и вставьте эту ссылку в браузер:" + subject: Ссылка для входа администратора + user_mailer: + magic_link: + button: Войти + expiry: Ссылка действительна 15 минут. + greeting: "Здравствуйте, %{name}!" + heading: Вход в аккаунт + ignore: Если вы не запрашивали это, просто проигнорируйте это письмо. + instruction: "Нажмите на кнопку ниже, чтобы войти в свой аккаунт:" + link_instruction: "Или скопируйте и вставьте эту ссылку в браузер:" + subject: Ссылка для входа + team_invitation: + button: Присоединиться к команде + expiry: Ссылка действительна 7 дней. + greeting: "Здравствуйте, %{name}!" + heading: "Вас пригласили в команду %{team_name}" + ignore: Если вы не ожидали это приглашение, просто проигнорируйте это письмо. + instruction: "%{inviter_name} приглашает вас в команду %{team_name}. Нажмите на кнопку ниже, чтобы принять приглашение:" + link_instruction: "Или скопируйте и вставьте эту ссылку в браузер:" + subject: "Вас пригласили в команду %{team_name}" diff --git a/config/locales/ru/views/admins/sessions.yml b/config/locales/ru/views/admins/sessions.yml new file mode 100644 index 0000000..d83e1d6 --- /dev/null +++ b/config/locales/ru/views/admins/sessions.yml @@ -0,0 +1,13 @@ +--- +ru: + admins: + sessions: + new: + email_help: Вход только для существующих администраторов. Вы получите ссылку на почту. + email_label: Почта администратора + email_placeholder: admin@example.com + heading: Панель администратора + restricted_area: Ограниченный доступ + restricted_message: Это вход для администраторов. Если вам нужен доступ, свяжитесь с администратором. + subheading: Войдите по ссылке для администратора + submit: Отправить ссылку для входа diff --git a/config/locales/ru/views/articles.yml b/config/locales/ru/views/articles.yml new file mode 100644 index 0000000..a492384 --- /dev/null +++ b/config/locales/ru/views/articles.yml @@ -0,0 +1,20 @@ +--- +ru: + articles: + edit: + heading: Редактировать статью + form: + body_label: Текст + body_placeholder: Напишите содержание статьи + title_label: Заголовок + title_placeholder: Введите заголовок статьи + index: + empty: Статей пока нет. Создайте первую статью. + heading: Статьи + new_article: Новая статья + new: + heading: Новая статья + show: + delete: Удалить + delete_confirm: Вы уверены, что хотите удалить эту статью? + edit: Редактировать diff --git a/config/locales/ru/views/billing.yml b/config/locales/ru/views/billing.yml new file mode 100644 index 0000000..ccb466f --- /dev/null +++ b/config/locales/ru/views/billing.yml @@ -0,0 +1,27 @@ +--- +ru: + teams: + billing: + show: + cancel_confirm: Вы уверены, что хотите отменить подписку? Доступ сохранится до конца расчётного периода. + cancel_subscription: Отменить подписку + cancellation_notice: "Ваша подписка завершится %{date}" + cancellation_notice_label: Отмена запланирована + days_remaining: + one: "Остался %{count} день" + few: "Осталось %{count} дня" + many: "Осталось %{count} дней" + other: "Осталось %{count} дней" + heading: Оплата + manage_in_stripe: Управление в Stripe + period_ends_label: Текущий период заканчивается + resume_subscription: Возобновить подписку + status: + active: Активна + canceling: Отменяется + inactive: Неактивна + past_due: Просрочена + trialing: Пробный период + status_label: Статус подписки + trial_ends_label: Пробный период до + view_plans: Смотреть тарифы diff --git a/config/locales/ru/views/chats.yml b/config/locales/ru/views/chats.yml new file mode 100644 index 0000000..e40f9d2 --- /dev/null +++ b/config/locales/ru/views/chats.yml @@ -0,0 +1,25 @@ +--- +ru: + chats: + form: + default_model: "По умолчанию (%{name})" + default_model_not_set: По умолчанию (не задано) + prompt_placeholder: О чём хотите поговорить? + index: + empty: + cta: Начать первый чат + description: Начните разговор с AI-ассистентом + heading: Чатов пока нет + heading: Ваши чаты + new_chat: Новый чат + no_messages: Сообщений пока нет + subheading: Продолжите разговор или начните новый + time_ago: "%{time} назад" + title: Чаты + new: + back: Назад к чатам + heading: Начать новый чат + subheading: Выберите модель и начните разговор + title: Новый чат + show: + title: Чат diff --git a/config/locales/ru/views/home.yml b/config/locales/ru/views/home.yml new file mode 100644 index 0000000..b857ea9 --- /dev/null +++ b/config/locales/ru/views/home.yml @@ -0,0 +1,25 @@ +--- +ru: + home: + index: + authentication: + description: Вы вошли с помощью магической ссылки. Никаких паролей! + heading: Аутентификация + secure_description: При каждом входе вы будете получать новую ссылку на почту. + secure_title: Безопасно и без паролей + getting_started: + description: Это шаблонное приложение. Вы можете настроить эту панель или добавить свои функции. + heading: Начало работы + tip_claude: Смотрите + tip_claude_suffix: для архитектурных рекомендаций + tip_controllers: Изучите код в + tip_models: Добавьте свои модели, контроллеры и представления + tip_views: Измените это представление в + profile: + email: Почта + heading: Ваш профиль + member_since: Участник с + name: Имя + signed_in: Вы успешно вошли в систему + title: Главная + welcome: "Добро пожаловать, %{name}!" diff --git a/config/locales/ru/views/languages.yml b/config/locales/ru/views/languages.yml new file mode 100644 index 0000000..2279138 --- /dev/null +++ b/config/locales/ru/views/languages.yml @@ -0,0 +1,14 @@ +--- +ru: + teams: + languages: + index: + active_description: Контент будет автоматически переведён на эти языки. + active_heading: Активные языки + add_button: Добавить язык + add_description: Добавьте язык для автоматического перевода контента. + add_heading: Добавить язык + heading: Языки + remove_confirm: "Удалить %{language} из языков команды?" + required: Обязательный + select_language: "Выберите язык..." diff --git a/config/locales/ru/views/madmin/mail.yml b/config/locales/ru/views/madmin/mail.yml new file mode 100644 index 0000000..060d09d --- /dev/null +++ b/config/locales/ru/views/madmin/mail.yml @@ -0,0 +1,26 @@ +--- +ru: + madmin: + mail: + edit: + cancel: Отмена + general: Основные + heading: Редактировать настройки почты + key_placeholder: "Введите ключ..." + mail_from: Адрес отправителя + save: Сохранить настройки + smtp: SMTP + smtp_address: Адрес сервера + smtp_password: Пароль + smtp_username: Имя пользователя + smtp_username_placeholder: your-username + show: + edit_button: Редактировать настройки + general: Основные + heading: Настройки почты + mail_from: Адрес отправителя + not_configured: Не настроено + smtp: SMTP + smtp_address: Адрес сервера + smtp_password: Пароль + smtp_username: Имя пользователя diff --git a/config/locales/ru/views/madmin/prices.yml b/config/locales/ru/views/madmin/prices.yml new file mode 100644 index 0000000..45bcda2 --- /dev/null +++ b/config/locales/ru/views/madmin/prices.yml @@ -0,0 +1,14 @@ +--- +ru: + madmin: + prices: + show: + heading: Цены Stripe + no_prices: Цены не найдены. Создайте продукты в Stripe Dashboard, затем синхронизируйте. + price_amount: Сумма + price_id: ID цены + price_interval: Интервал + price_product: Продукт + stripe_not_configured: Stripe не настроен + stripe_not_configured_hint: Добавьте API-ключи Stripe в настройках для управления ценами. + sync_prices: Синхронизировать цены diff --git a/config/locales/ru/views/madmin/providers.yml b/config/locales/ru/views/madmin/providers.yml new file mode 100644 index 0000000..f943381 --- /dev/null +++ b/config/locales/ru/views/madmin/providers.yml @@ -0,0 +1,35 @@ +--- +ru: + madmin: + providers: + index: + description: Настройте API-ключи для AI-провайдеров. Новые провайдеры появляются автоматически при поддержке RubyLLM. + heading: AI-провайдеры + key_placeholder: "Введите ключ..." + placeholders: + api_base: "https://api.example.com" + location: us-central1 + region: us-east-1 + providers: + anthropic: Anthropic + bedrock: Amazon Bedrock + deepseek: DeepSeek + gemini: Google Gemini + gpustack: GPUStack + mistral: Mistral + ollama: Ollama + openai: OpenAI + openrouter: OpenRouter + perplexity: Perplexity + vertexai: Google Vertex AI + xai: xAI + save: Сохранить провайдеры + settings: + api_base: Базовый URL + api_key: API-ключ + location: Расположение + organization_id: ID организации + project_id: ID проекта + region: Регион + secret_key: Секретный ключ + session_token: Токен сессии diff --git a/config/locales/ru/views/madmin/settings.yml b/config/locales/ru/views/madmin/settings.yml new file mode 100644 index 0000000..9e30011 --- /dev/null +++ b/config/locales/ru/views/madmin/settings.yml @@ -0,0 +1,34 @@ +--- +ru: + madmin: + settings: + edit: + cancel: Отмена + heading: Редактировать настройки + key_placeholder: "Введите ключ..." + litestream: Litestream + litestream_replica_access_key: Секретный ключ доступа + litestream_replica_bucket: Бакет реплики + litestream_replica_key_id: ID ключа доступа + litestream_restart_note: Изменения настроек Litestream требуют перезапуска сервера. + save: Сохранить настройки + stripe: Stripe + stripe_publishable_key: Публичный ключ + stripe_secret_key: Секретный ключ + stripe_webhook_secret: Секрет вебхука + trial_days: Пробный период (дни) + trial_days_hint: Количество дней бесплатного пробного периода для новых команд. 0 — отключить. + show: + edit_button: Редактировать настройки + heading: Настройки + litestream: Litestream + litestream_replica_access_key: Секретный ключ доступа + litestream_replica_bucket: Бакет реплики + litestream_replica_key_id: ID ключа доступа + litestream_restart_note: Изменения настроек Litestream требуют перезапуска сервера. + not_configured: Не настроено + stripe: Stripe + stripe_publishable_key: Публичный ключ + stripe_secret_key: Секретный ключ + stripe_webhook_secret: Секрет вебхука + trial_days: Пробный период (дни) diff --git a/config/locales/ru/views/messages.yml b/config/locales/ru/views/messages.yml new file mode 100644 index 0000000..bce16cf --- /dev/null +++ b/config/locales/ru/views/messages.yml @@ -0,0 +1,5 @@ +--- +ru: + messages: + form: + placeholder: "Ответить..." diff --git a/config/locales/ru/views/models.yml b/config/locales/ru/views/models.yml new file mode 100644 index 0000000..e77c6da --- /dev/null +++ b/config/locales/ru/views/models.yml @@ -0,0 +1,28 @@ +--- +ru: + models: + index: + back: Назад к чатам + heading: Модели + refresh: Обновить модели + table: + context_window: Контекстное окно + model: Модель + pricing: "$/1М токенов (Вход/Выход)" + provider: Провайдер + title: Модели + model: + show: Подробнее + start_chat: Начать чат + show: + all_models: Все модели + back: Назад к чатам + capabilities: Возможности + context_window: Контекстное окно + context_window_value: "%{count} токенов" + id: ID + max_output: Макс. вывод + max_output_value: "%{count} токенов" + model: Модель + provider: Провайдер + start_chat: Начать чат с этой моделью diff --git a/config/locales/ru/views/onboardings.yml b/config/locales/ru/views/onboardings.yml new file mode 100644 index 0000000..fe05050 --- /dev/null +++ b/config/locales/ru/views/onboardings.yml @@ -0,0 +1,13 @@ +--- +ru: + onboardings: + show: + heading: "Добро пожаловать! Давайте начнём" + name_label: Как вас зовут? + name_placeholder: Введите ваше имя + subheading: Пара вопросов перед началом работы. + submit: Продолжить + team_name_help: Вы сможете изменить это позже в настройках команды. + team_name_label: Назовите вашу команду + team_name_placeholder: Моя команда + title: Начало работы diff --git a/config/locales/ru/views/pricing.yml b/config/locales/ru/views/pricing.yml new file mode 100644 index 0000000..2565594 --- /dev/null +++ b/config/locales/ru/views/pricing.yml @@ -0,0 +1,19 @@ +--- +ru: + teams: + pricing: + show: + already_subscribed: У вашей команды уже есть активная подписка. + description: Выберите подходящий тариф для вашей команды + heading: "Тарифы и цены" + manage_billing: Управление оплатой + month: месяц + monthly: Ежемесячно + subscribe: Подписаться + trial_active: + one: "Пробный период заканчивается через %{count} день. Оформите подписку, чтобы сохранить доступ." + few: "Пробный период заканчивается через %{count} дня. Оформите подписку, чтобы сохранить доступ." + many: "Пробный период заканчивается через %{count} дней. Оформите подписку, чтобы сохранить доступ." + other: "Пробный период заканчивается через %{count} дней. Оформите подписку, чтобы сохранить доступ." + year: год + yearly: Ежегодно diff --git a/config/locales/ru/views/profiles.yml b/config/locales/ru/views/profiles.yml new file mode 100644 index 0000000..18dd88f --- /dev/null +++ b/config/locales/ru/views/profiles.yml @@ -0,0 +1,21 @@ +--- +ru: + profiles: + edit: + avatar_label: Аватар + heading: Редактировать профиль + locale_auto: Автоопределение + locale_help: Устанавливает язык интерфейса и исходный язык для переводов + locale_label: Язык + name_label: Имя + name_placeholder: Введите ваше имя + submit: Сохранить + show: + edit_button: Редактировать + email: Почта + heading: Профиль + language: Язык + locale_auto: Автоопределение + member_since: Участник с + name: Имя + title: Профиль diff --git a/config/locales/ru/views/sessions.yml b/config/locales/ru/views/sessions.yml new file mode 100644 index 0000000..132160b --- /dev/null +++ b/config/locales/ru/views/sessions.yml @@ -0,0 +1,10 @@ +--- +ru: + sessions: + new: + email_help: Мы отправим вам ссылку для входа. Если у вас нет аккаунта, он будет создан автоматически. + email_label: Электронная почта + email_placeholder: you@example.com + heading: Добро пожаловать + subheading: Войдите по ссылке, отправленной на вашу почту + submit: Отправить ссылку для входа diff --git a/config/locales/ru/views/shared.yml b/config/locales/ru/views/shared.yml new file mode 100644 index 0000000..f71eb95 --- /dev/null +++ b/config/locales/ru/views/shared.yml @@ -0,0 +1,17 @@ +--- +ru: + shared: + image_upload: + drop_image: Перетащите изображение + uploading: Загрузка… + sidebar: + articles: Статьи + billing: Оплата + chats: Чаты + collapse: Свернуть боковую панель + home: Главная + languages: Языки + members: Участники + new_chat: Новый чат + settings: Настройки + user: Пользователь diff --git a/config/locales/ru/views/teams.yml b/config/locales/ru/views/teams.yml new file mode 100644 index 0000000..8ee89ed --- /dev/null +++ b/config/locales/ru/views/teams.yml @@ -0,0 +1,60 @@ +--- +ru: + teams: + index: + create_team: Создать команду + heading: Выберите команду + members: + index: + admin: Администратор + heading: Участники команды + invite_button: Пригласить участника + invited_by: Приглашён + member: Участник + owner: Владелец + remove: Удалить + role: Роль + new: + description: Мы отправим приглашение на электронную почту. + email_label: Электронная почта + email_placeholder: colleague@example.com + heading: Пригласить участника + submit: Отправить приглашение + show: + admin: Администратор + back: Назад к участникам + email: Почта + heading: Профиль участника + invited_by: Приглашён + joined: Присоединился + member: Участник + name: Имя + owner: Владелец + role: Роль + title: "%{name}" + new: + heading: Создать новую команду + name_label: Название команды + name_placeholder: Введите название команды + submit: Создать команду + settings: + edit: + back: "← Назад к настройкам" + heading: Редактировать настройки команды + logo_label: Логотип + name_available: Название доступно + name_label: Название команды + name_placeholder: Введите название команды + name_taken: Название уже занято + submit: Сохранить + show: + api_key_description: Используйте этот API-ключ для аутентификации MCP-запросов этой команды. + api_key_heading: API-ключ + edit_button: Редактировать + heading: Настройки команды + logo_label: Логотип + name_label: Название команды + no_logo: Логотип не загружен + regenerate_api_key: Перегенерировать + regenerate_confirm: Вы уверены? Текущий API-ключ станет недействительным. + slug_label: URL команды diff --git a/config/puma.rb b/config/puma.rb index 66a58ff..e5cbdc6 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -40,5 +40,7 @@ # In other environments, only set the PID file if requested. pidfile ENV["PIDFILE"] if ENV["PIDFILE"] -# Run litestream only in production. -plugin :litestream if ENV.fetch("RAILS_ENV", "production") == "production" +# Run litestream only in production when explicitly enabled via env var. +if ENV.fetch("RAILS_ENV", "production") == "production" && ENV["LITESTREAM_ENABLED"].present? + plugin :litestream +end diff --git a/config/routes.rb b/config/routes.rb index 20bfc7a..193e9bd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,36 +1,78 @@ Rails.application.routes.draw do - devise_for :users, controllers: { omniauth_callbacks: "users/omniauth_callbacks" } + draw :madmin - # Cross-domain auth routes (must be early) + # GitHub OAuth routes (OmniAuth handles /auth/github via Rack middleware) + match "auth/github/callback", to: "users/omniauth_callbacks#github", via: [ :get, :post ] + get "auth/failure", to: "users/omniauth_callbacks#failure" + match "sign_in_github", to: "users/sessions#github_auth", as: :github_auth_with_return, via: [ :get, :post ] + delete "sign_out", to: "users/sessions#destroy", as: :destroy_user_session + + # Cross-domain auth routes (must be before wildcard auth/:token) get "auth/receive", to: "auth#receive" get "auth/sign_out_receive", to: "auth#sign_out_receive" + # User magic link verification + get "auth/:token", to: "sessions/verifications#show", as: :verify_magic_link + + # Admin authentication (magic link - separate from user auth) + namespace :admins do + resource :session, only: [ :new, :create, :destroy ] + get "auth/:token", to: "sessions/verifications#show", as: :verify_magic_link + end + # Production community domain: rubycommunity.org (users at root level /:username) - # Development fallback: localhost:3003/community/:id (see routes below) - # When linking to user profiles, use community_user_canonical_url(user) helper - # which resolves to the correct domain per environment. constraints host: Rails.application.config.x.domains.community do root "users#index", as: :rubycommunity_root - get "map_data", to: "users#map_data", as: :rubycommunity_map_data - # Redirect /community paths that may linger from old links or crawlers + get "map_data", to: "users/map_data#show", as: :rubycommunity_map_data get "community", to: redirect("/", status: 301) get "community/:id", to: redirect("/%{id}", status: 301), constraints: { id: /[^\/]+/ } get ":id", to: "users#show", as: :rubycommunity_user, constraints: { id: /[^\/\.]+/ } end - # Add sign out route for OmniAuth-only authentication - devise_scope :user do - delete "sign_out", to: "users/sessions#destroy", as: :destroy_user_session - match "sign_in_github", to: "users/sessions#github_auth", as: :github_auth_with_return, via: [ :get, :post ] + # Litestream backup UI - only accessible to admin users + mount Litestream::Engine, at: "/litestream" + + # Onboarding (first-time user setup, before team context) + resource :onboarding, only: [ :show, :update ] + + # Team management (multi-tenant only routes for listing/creating teams) + resources :teams, only: [ :index, :new, :create ], param: :slug + + # Team-scoped routes + scope "/t/:team_slug", as: :team do + root "home#index", as: :root + resources :chats do + resources :messages, only: [ :create ] + end + resources :models, only: [ :index, :show ] + resource :models_refresh, only: [ :create ], controller: "models/refreshes" + + # Team settings (multi-tenant only) + resource :settings, only: [ :show, :edit, :update ], controller: "teams/settings" do + resource :api_key_regeneration, only: [ :create ], controller: "teams/settings/api_key_regenerations" + end + resource :name_check, only: [ :show ], controller: "teams/name_checks" + resources :members, only: [ :index, :show, :new, :create, :destroy ], controller: "teams/members" + resource :profile, only: [ :show, :edit, :update ], controller: "profiles" + + # Content + resources :articles + resources :languages, only: [ :index, :create, :destroy ], controller: "teams/languages" + + # Billing + resource :pricing, only: [ :show ], controller: "teams/pricing" + resource :billing, only: [ :show ], controller: "teams/billing" + resource :checkout, only: [ :create ], controller: "teams/checkouts" + resource :subscription_cancellation, only: [ :create, :destroy ], controller: "teams/subscription_cancellations" end - # Admin panel - only accessible to users with admin role - authenticate :user, ->(user) { user.admin? } do - mount Avo::Engine, at: Avo.configuration.root_path - mount Litestream::Engine, at: "/litestream" + # Webhooks + namespace :webhooks do + resource :stripe, only: [ :create ], controller: "stripe" end - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + # OG Image preview page (screenshot at 1200x630 for social sharing) + get "og-image", to: "og_images#show" # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. @@ -50,20 +92,21 @@ # Community routes — local development fallback for rubycommunity.org (see domain constraint above) get "community", to: "users#index", as: :users - get "community/map_data", to: "users#map_data", as: :community_map_data + get "community/map_data", to: "users/map_data#show", as: :community_map_data get "community/:id", to: "users#show", as: :user - # Tags route (keeping as resources for now) - resources :tags, only: [ :show ] do - collection do - get :search - end + # Tags routes + resources :tags, only: [ :show ] + namespace :tags do + resource :search, only: [ :show ] end - # Post collection actions (preview, metadata, etc) - post "posts/preview", to: "posts#preview", as: :preview_posts - post "posts/fetch_metadata", to: "posts#fetch_metadata", as: :fetch_metadata_posts - post "posts/check_duplicate_url", to: "posts#check_duplicate_url", as: :check_duplicate_url_posts + # Post resource actions (nested) + namespace :posts do + resource :preview, only: [ :create ] + resource :metadata, only: [ :create ] + resource :duplicate_check, only: [ :create ] + end # New and edit routes for posts (need to be defined before dynamic routes) get "posts/new", to: "posts#new", as: :new_post @@ -81,13 +124,12 @@ # Testimonials (singular resource - one per user) resource :testimonial, only: [ :create, :update ] - # User settings routes - resource :user_settings, only: [], controller: "user_settings" do - post :toggle_public, on: :collection - post :toggle_open_to_work, on: :collection - post :toggle_newsletter, on: :collection - post :hide_repo, on: :collection - post :unhide_repo, on: :collection + # User settings (nested singular resources for each toggle) + namespace :user_settings do + resource :visibility, only: [ :update ] + resource :open_to_work, only: [ :update ] + resource :newsletter, only: [ :update ] + resource :repository_hide, only: [ :create, :destroy ] end # Newsletter unsubscribe @@ -95,7 +137,7 @@ get "newsletter/open/:token", to: "newsletter_opens#show", as: :newsletter_open # OG image preview pages (for screenshotting) - get "og-image-community", to: "users#og_image" + get "og-image-community", to: "users/og_images#show" # Legal pages (must be before catch-all routes) get "legal/privacy", to: "legal#show", defaults: { page: "privacy_policy" }, as: :privacy_policy @@ -109,7 +151,16 @@ # Post routes (must be after category) get ":category_id/:id", to: "posts#show", as: :post, constraints: { category_id: /[^\/\.]+/, id: /[^\/\.]+/ } - get ":category_id/:id/og-image.webp", to: "posts#image", as: :post_image, constraints: { category_id: /[^\/]+/, id: /[^\/]+/ } + get ":category_id/:id/og-image.webp", to: "posts/images#show", as: :post_image, constraints: { category_id: /[^\/]+/, id: /[^\/]+/ } + + # Test-only route for setting session in integration tests + if Rails.env.test? + post "/_test_sign_in", to: ->(env) { + request = Rack::Request.new(env) + env["rack.session"][:user_id] = request.params["user_id"] + [ 200, { "Content-Type" => "text/plain" }, [ "OK" ] ] + } + end # Defines the root path route ("/") root "home#index" diff --git a/config/routes/madmin.rb b/config/routes/madmin.rb new file mode 100644 index 0000000..1862b87 --- /dev/null +++ b/config/routes/madmin.rb @@ -0,0 +1,62 @@ +# Below are the routes for madmin +namespace :madmin do + namespace :active_storage do + resources :variant_records + end + namespace :active_storage do + resources :attachments + end + namespace :active_storage do + resources :blobs + end + resources :admins do + member do + post :send_magic_link + end + end + resources :chats do + collection do + patch :toggle_public_chats + end + end + resources :messages + resources :models do + collection do + post :refresh_all + end + end + resources :tool_calls + resources :users + resources :teams + + # Content management + resources :posts + resources :categories + resources :comments + resources :reports + resources :tags + resources :testimonials + resources :projects + + resources :languages do + collection do + post :sync + end + member do + patch :toggle + end + end + resource :settings, only: [ :show, :edit, :update ] do + resource :ai_models, only: [ :show, :edit, :update ], controller: "settings/ai_models" + end + resources :providers, only: [ :index ] do + collection do + patch :update + end + end + resource :prices, only: [ :show ] do + post :sync, on: :member + end + resource :mail, only: [ :show, :edit, :update ], controller: "mail" + root to: "dashboard#show" +end diff --git a/config/tailwind.config.js b/config/tailwind.config.js deleted file mode 100644 index 4e59ed5..0000000 --- a/config/tailwind.config.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = { - content: [ - './public/*.html', - './app/helpers/**/*.rb', - './app/javascript/**/*.js', - './app/views/**/*.{erb,haml,html,slim}' - ], - theme: { - extend: { - fontFamily: { - sans: ['Inter var', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'], - mono: ['Martian Mono', 'ui-monospace', 'SFMono-Regular', 'SF Mono', 'Menlo', 'Consolas', 'Liberation Mono', 'monospace'], - }, - }, - }, - plugins: [] -} diff --git a/db/migrate/20241207165010_devise_create_users.rb b/db/migrate/20241207165010_devise_create_users.rb deleted file mode 100644 index e29ca19..0000000 --- a/db/migrate/20241207165010_devise_create_users.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -class DeviseCreateUsers < ActiveRecord::Migration[8.1] - def change - create_table :users, force: true, id: false do |t| - t.primary_key :id, :string, default: -> { "ULID()" } - - ## Database authenticatable - t.string :email, null: false, default: "" - t.string :encrypted_password, null: false, default: "" - - ## Recoverable - t.string :reset_password_token - t.datetime :reset_password_sent_at - - ## Rememberable - t.datetime :remember_created_at - - ## Trackable - # t.integer :sign_in_count, default: 0, null: false - # t.datetime :current_sign_in_at - # t.datetime :last_sign_in_at - # t.string :current_sign_in_ip - # t.string :last_sign_in_ip - - ## Confirmable - # t.string :confirmation_token - # t.datetime :confirmed_at - # t.datetime :confirmation_sent_at - # t.string :unconfirmed_email # Only if using reconfirmable - - ## Lockable - # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts - # t.string :unlock_token # Only if unlock strategy is :email or :both - # t.datetime :locked_at - - t.string :name - - t.timestamps null: false - end - - add_index :users, :email, unique: true - add_index :users, :reset_password_token, unique: true - # add_index :users, :confirmation_token, unique: true - # add_index :users, :unlock_token, unique: true - end -end diff --git a/db/migrate/20241207205829_devise_create_admins.rb b/db/migrate/20241207205829_devise_create_admins.rb deleted file mode 100644 index 0d2ed82..0000000 --- a/db/migrate/20241207205829_devise_create_admins.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -class DeviseCreateAdmins < ActiveRecord::Migration[8.1] - def change - create_table :admins, force: true, id: false do |t| - t.primary_key :id, :string, default: -> { "ULID()" } - - ## Database authenticatable - t.string :email, null: false, default: "" - t.string :encrypted_password, null: false, default: "" - - ## Recoverable - t.string :reset_password_token - t.datetime :reset_password_sent_at - - ## Rememberable - t.datetime :remember_created_at - - ## Trackable - # t.integer :sign_in_count, default: 0, null: false - # t.datetime :current_sign_in_at - # t.datetime :last_sign_in_at - # t.string :current_sign_in_ip - # t.string :last_sign_in_ip - - ## Confirmable - # t.string :confirmation_token - # t.datetime :confirmed_at - # t.datetime :confirmation_sent_at - # t.string :unconfirmed_email # Only if using reconfirmable - - ## Lockable - # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts - # t.string :unlock_token # Only if unlock strategy is :email or :both - # t.datetime :locked_at - - - t.timestamps null: false - end - - add_index :admins, :email, unique: true - add_index :admins, :reset_password_token, unique: true - # add_index :admins, :confirmation_token, unique: true - # add_index :admins, :unlock_token, unique: true - end -end diff --git a/db/migrate/20260109173042_create_admins.rb b/db/migrate/20260109173042_create_admins.rb new file mode 100644 index 0000000..9d87cbf --- /dev/null +++ b/db/migrate/20260109173042_create_admins.rb @@ -0,0 +1,10 @@ +class CreateAdmins < ActiveRecord::Migration[8.2] + def change + create_table :admins, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.string :email + + t.timestamps + end + add_index :admins, :email, unique: true + end +end diff --git a/db/migrate/20260109224551_create_chats.rb b/db/migrate/20260109224551_create_chats.rb new file mode 100644 index 0000000..0152bff --- /dev/null +++ b/db/migrate/20260109224551_create_chats.rb @@ -0,0 +1,7 @@ +class CreateChats < ActiveRecord::Migration[8.2] + def change + create_table :chats, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.timestamps + end + end +end diff --git a/db/migrate/20260109224552_create_messages.rb b/db/migrate/20260109224552_create_messages.rb new file mode 100644 index 0000000..a2019d8 --- /dev/null +++ b/db/migrate/20260109224552_create_messages.rb @@ -0,0 +1,16 @@ +class CreateMessages < ActiveRecord::Migration[8.2] + def change + create_table :messages, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.string :role, null: false + t.text :content + t.json :content_raw + t.integer :input_tokens + t.integer :output_tokens + t.integer :cached_tokens + t.integer :cache_creation_tokens + t.timestamps + end + + add_index :messages, :role + end +end diff --git a/db/migrate/20260109224553_create_tool_calls.rb b/db/migrate/20260109224553_create_tool_calls.rb new file mode 100644 index 0000000..ddd6bc6 --- /dev/null +++ b/db/migrate/20260109224553_create_tool_calls.rb @@ -0,0 +1,15 @@ +class CreateToolCalls < ActiveRecord::Migration[8.2] + def change + create_table :tool_calls, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.string :tool_call_id, null: false + t.string :name, null: false + + t.json :arguments, default: {} + + t.timestamps + end + + add_index :tool_calls, :tool_call_id, unique: true + add_index :tool_calls, :name + end +end diff --git a/db/migrate/20260109224555_create_models.rb b/db/migrate/20260109224555_create_models.rb new file mode 100644 index 0000000..b113f9f --- /dev/null +++ b/db/migrate/20260109224555_create_models.rb @@ -0,0 +1,31 @@ +class CreateModels < ActiveRecord::Migration[8.2] + def change + create_table :models, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.string :model_id, null: false + t.string :name, null: false + t.string :provider, null: false + t.string :family + t.datetime :model_created_at + t.integer :context_window + t.integer :max_output_tokens + t.date :knowledge_cutoff + + t.json :modalities, default: {} + t.json :capabilities, default: [] + t.json :pricing, default: {} + t.json :metadata, default: {} + + t.timestamps + + t.index [ :provider, :model_id ], unique: true + t.index :provider + t.index :family + end + + # Load models from JSON + say_with_time "Loading models from models.json" do + RubyLLM.models.load_from_json! + Model.save_to_database + end + end +end diff --git a/db/migrate/20260109224556_add_references_to_chats_tool_calls_and_messages.rb b/db/migrate/20260109224556_add_references_to_chats_tool_calls_and_messages.rb new file mode 100644 index 0000000..8be06b9 --- /dev/null +++ b/db/migrate/20260109224556_add_references_to_chats_tool_calls_and_messages.rb @@ -0,0 +1,9 @@ +class AddReferencesToChatsToolCallsAndMessages < ActiveRecord::Migration[8.2] + def change + add_reference :chats, :model, type: :string, foreign_key: true + add_reference :tool_calls, :message, type: :string, null: false, foreign_key: true + add_reference :messages, :chat, type: :string, null: false, foreign_key: true + add_reference :messages, :model, type: :string, foreign_key: true + add_reference :messages, :tool_call, type: :string, foreign_key: true + end +end diff --git a/db/migrate/20260109230202_add_user_to_chats.rb b/db/migrate/20260109230202_add_user_to_chats.rb new file mode 100644 index 0000000..46aa785 --- /dev/null +++ b/db/migrate/20260109230202_add_user_to_chats.rb @@ -0,0 +1,5 @@ +class AddUserToChats < ActiveRecord::Migration[8.2] + def change + add_reference :chats, :user, null: false, foreign_key: true, type: :string + end +end diff --git a/db/migrate/20260114144530_add_cost_to_messages.rb b/db/migrate/20260114144530_add_cost_to_messages.rb new file mode 100644 index 0000000..56e5824 --- /dev/null +++ b/db/migrate/20260114144530_add_cost_to_messages.rb @@ -0,0 +1,5 @@ +class AddCostToMessages < ActiveRecord::Migration[8.2] + def change + add_column :messages, :cost, :decimal, precision: 10, scale: 6, default: 0.0 + end +end diff --git a/db/migrate/20260116182900_add_ruby_llm_v1_10_columns.rb b/db/migrate/20260116182900_add_ruby_llm_v1_10_columns.rb new file mode 100644 index 0000000..11300f8 --- /dev/null +++ b/db/migrate/20260116182900_add_ruby_llm_v1_10_columns.rb @@ -0,0 +1,19 @@ +class AddRubyLlmV110Columns < ActiveRecord::Migration[8.2] + def change + unless column_exists?(:messages, :thinking_text) + add_column :messages, :thinking_text, :text + end + + unless column_exists?(:messages, :thinking_signature) + add_column :messages, :thinking_signature, :text + end + + unless column_exists?(:messages, :thinking_tokens) + add_column :messages, :thinking_tokens, :integer + end + + unless column_exists?(:tool_calls, :thought_signature) + add_column :tool_calls, :thought_signature, :string + end + end +end diff --git a/db/migrate/20260116210656_add_cache_columns_for_madmin.rb b/db/migrate/20260116210656_add_cache_columns_for_madmin.rb new file mode 100644 index 0000000..b4cca79 --- /dev/null +++ b/db/migrate/20260116210656_add_cache_columns_for_madmin.rb @@ -0,0 +1,14 @@ +class AddCacheColumnsForMadmin < ActiveRecord::Migration[8.2] + def change + # Add counter cache columns to models + add_column :models, :chats_count, :integer, default: 0, null: false + add_column :models, :total_cost, :decimal, precision: 12, scale: 6, default: 0, null: false + + # Add counter cache columns to chats + add_column :chats, :messages_count, :integer, default: 0, null: false + add_column :chats, :total_cost, :decimal, precision: 12, scale: 6, default: 0, null: false + + # Add counter cache column to users + add_column :users, :total_cost, :decimal, precision: 12, scale: 6, default: 0, null: false + end +end diff --git a/db/migrate/20260126215238_add_api_key_to_users.rb b/db/migrate/20260126215238_add_api_key_to_users.rb new file mode 100644 index 0000000..78c3cb4 --- /dev/null +++ b/db/migrate/20260126215238_add_api_key_to_users.rb @@ -0,0 +1,6 @@ +class AddApiKeyToUsers < ActiveRecord::Migration[8.2] + def change + add_column :users, :api_key, :string + add_index :users, :api_key, unique: true + end +end diff --git a/db/migrate/20260127223309_remove_circular_foreign_key_on_messages_tool_call.rb b/db/migrate/20260127223309_remove_circular_foreign_key_on_messages_tool_call.rb new file mode 100644 index 0000000..b642c92 --- /dev/null +++ b/db/migrate/20260127223309_remove_circular_foreign_key_on_messages_tool_call.rb @@ -0,0 +1,5 @@ +class RemoveCircularForeignKeyOnMessagesToolCall < ActiveRecord::Migration[8.2] + def change + remove_foreign_key :messages, :tool_calls + end +end diff --git a/db/migrate/20260130163220_create_teams.rb b/db/migrate/20260130163220_create_teams.rb new file mode 100644 index 0000000..43be414 --- /dev/null +++ b/db/migrate/20260130163220_create_teams.rb @@ -0,0 +1,12 @@ +class CreateTeams < ActiveRecord::Migration[8.2] + def change + create_table :teams, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.string :name, null: false + t.string :slug, null: false + + t.timestamps + end + + add_index :teams, :slug, unique: true + end +end diff --git a/db/migrate/20260130163238_create_memberships.rb b/db/migrate/20260130163238_create_memberships.rb new file mode 100644 index 0000000..3eb7881 --- /dev/null +++ b/db/migrate/20260130163238_create_memberships.rb @@ -0,0 +1,14 @@ +class CreateMemberships < ActiveRecord::Migration[8.2] + def change + create_table :memberships, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.references :user, null: false, foreign_key: true, type: :string + t.references :team, null: false, foreign_key: true, type: :string + t.references :invited_by, foreign_key: { to_table: :users }, type: :string + t.string :role, null: false, default: "member" + + t.timestamps + end + + add_index :memberships, [ :user_id, :team_id ], unique: true + end +end diff --git a/db/migrate/20260130163257_add_team_to_chats.rb b/db/migrate/20260130163257_add_team_to_chats.rb new file mode 100644 index 0000000..ac6a4fb --- /dev/null +++ b/db/migrate/20260130163257_add_team_to_chats.rb @@ -0,0 +1,5 @@ +class AddTeamToChats < ActiveRecord::Migration[8.2] + def change + add_reference :chats, :team, null: true, foreign_key: true, type: :string + end +end diff --git a/db/migrate/20260130163319_create_teams_for_existing_users.rb b/db/migrate/20260130163319_create_teams_for_existing_users.rb new file mode 100644 index 0000000..09835cd --- /dev/null +++ b/db/migrate/20260130163319_create_teams_for_existing_users.rb @@ -0,0 +1,43 @@ +class CreateTeamsForExistingUsers < ActiveRecord::Migration[8.2] + def up + # Skip model callbacks by using raw SQL/ActiveRecord base operations + User.find_each do |user| + team_name = "#{user.name || user.email.to_s.split('@').first}'s Team" + slug = team_name.parameterize + + # Ensure unique slug + base_slug = slug + counter = 1 + while Team.exists?(slug: slug) + slug = "#{base_slug}-#{counter}" + counter += 1 + end + + # Use insert to bypass model callbacks (api_key column may not exist yet) + team_id = SecureRandom.uuid_v7 + execute <<~SQL + INSERT INTO teams (id, name, slug, created_at, updated_at) + VALUES ('#{team_id}', #{connection.quote(team_name)}, #{connection.quote(slug)}, datetime('now'), datetime('now')) + SQL + + membership_id = SecureRandom.uuid_v7 + execute <<~SQL + INSERT INTO memberships (id, user_id, team_id, role, created_at, updated_at) + VALUES ('#{membership_id}', '#{user.id}', '#{team_id}', 'owner', datetime('now'), datetime('now')) + SQL + + # Assign all user's chats to their team + Chat.where(user: user, team: nil).update_all(team_id: team_id) + end + end + + def down + Membership.where(role: "owner").find_each do |membership| + team = membership.team + if team.memberships.count == 1 + team.chats.update_all(team_id: nil) + team.destroy + end + end + end +end diff --git a/db/migrate/20260130181636_add_api_key_to_teams.rb b/db/migrate/20260130181636_add_api_key_to_teams.rb new file mode 100644 index 0000000..034246a --- /dev/null +++ b/db/migrate/20260130181636_add_api_key_to_teams.rb @@ -0,0 +1,6 @@ +class AddApiKeyToTeams < ActiveRecord::Migration[8.2] + def change + add_column :teams, :api_key, :string + add_index :teams, :api_key, unique: true + end +end diff --git a/db/migrate/20260130181717_migrate_api_keys_to_teams.rb b/db/migrate/20260130181717_migrate_api_keys_to_teams.rb new file mode 100644 index 0000000..5664f3f --- /dev/null +++ b/db/migrate/20260130181717_migrate_api_keys_to_teams.rb @@ -0,0 +1,17 @@ +class MigrateApiKeysToTeams < ActiveRecord::Migration[8.2] + def up + # Generate API key for each team without one + execute <<-SQL + UPDATE teams + SET api_key = lower(hex(randomblob(32))) + WHERE api_key IS NULL + SQL + + # Make api_key not null after populating + change_column_null :teams, :api_key, false + end + + def down + change_column_null :teams, :api_key, true + end +end diff --git a/db/migrate/20260130181759_remove_api_key_from_users.rb b/db/migrate/20260130181759_remove_api_key_from_users.rb new file mode 100644 index 0000000..05b661d --- /dev/null +++ b/db/migrate/20260130181759_remove_api_key_from_users.rb @@ -0,0 +1,6 @@ +class RemoveApiKeyFromUsers < ActiveRecord::Migration[8.2] + def change + remove_index :users, :api_key + remove_column :users, :api_key, :string + end +end diff --git a/db/migrate/20260205182154_add_stripe_fields_to_teams.rb b/db/migrate/20260205182154_add_stripe_fields_to_teams.rb new file mode 100644 index 0000000..93607ff --- /dev/null +++ b/db/migrate/20260205182154_add_stripe_fields_to_teams.rb @@ -0,0 +1,11 @@ +class AddStripeFieldsToTeams < ActiveRecord::Migration[8.2] + def change + add_column :teams, :stripe_customer_id, :string + add_column :teams, :stripe_subscription_id, :string + add_column :teams, :subscription_status, :string + add_column :teams, :current_period_ends_at, :datetime + + add_index :teams, :stripe_customer_id, unique: true + add_index :teams, :stripe_subscription_id, unique: true + end +end diff --git a/db/migrate/20260205200000_create_settings.rb b/db/migrate/20260205200000_create_settings.rb new file mode 100644 index 0000000..52e66ca --- /dev/null +++ b/db/migrate/20260205200000_create_settings.rb @@ -0,0 +1,18 @@ +class CreateSettings < ActiveRecord::Migration[8.2] + def change + create_table :settings, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.string :openai_api_key + t.string :anthropic_api_key + t.string :stripe_secret_key + t.string :stripe_publishable_key + t.string :stripe_webhook_secret + t.string :smtp_address + t.string :smtp_username + t.string :smtp_password + t.string :litestream_replica_bucket + t.string :litestream_replica_key_id + t.string :litestream_replica_access_key + t.timestamps + end + end +end diff --git a/db/migrate/20260207173537_add_trial_days_to_settings.rb b/db/migrate/20260207173537_add_trial_days_to_settings.rb new file mode 100644 index 0000000..78c262e --- /dev/null +++ b/db/migrate/20260207173537_add_trial_days_to_settings.rb @@ -0,0 +1,5 @@ +class AddTrialDaysToSettings < ActiveRecord::Migration[8.2] + def change + add_column :settings, :trial_days, :integer, default: 30 + end +end diff --git a/db/migrate/20260207204410_add_cancel_at_period_end_to_teams.rb b/db/migrate/20260207204410_add_cancel_at_period_end_to_teams.rb new file mode 100644 index 0000000..bb4a7f7 --- /dev/null +++ b/db/migrate/20260207204410_add_cancel_at_period_end_to_teams.rb @@ -0,0 +1,5 @@ +class AddCancelAtPeriodEndToTeams < ActiveRecord::Migration[8.2] + def change + add_column :teams, :cancel_at_period_end, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20260208120150_create_provider_credentials_and_remove_ai_keys_from_settings.rb b/db/migrate/20260208120150_create_provider_credentials_and_remove_ai_keys_from_settings.rb new file mode 100644 index 0000000..41674e6 --- /dev/null +++ b/db/migrate/20260208120150_create_provider_credentials_and_remove_ai_keys_from_settings.rb @@ -0,0 +1,15 @@ +class CreateProviderCredentialsAndRemoveAiKeysFromSettings < ActiveRecord::Migration[8.2] + def change + create_table :provider_credentials, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.string :provider, null: false + t.string :key, null: false + t.string :value + t.timestamps + end + + add_index :provider_credentials, [ :provider, :key ], unique: true + + remove_column :settings, :openai_api_key, :string + remove_column :settings, :anthropic_api_key, :string + end +end diff --git a/db/migrate/20260208121032_add_public_chats_to_settings.rb b/db/migrate/20260208121032_add_public_chats_to_settings.rb new file mode 100644 index 0000000..2280304 --- /dev/null +++ b/db/migrate/20260208121032_add_public_chats_to_settings.rb @@ -0,0 +1,5 @@ +class AddPublicChatsToSettings < ActiveRecord::Migration[8.2] + def change + add_column :settings, :public_chats, :boolean, default: true, null: false + end +end diff --git a/db/migrate/20260208151854_add_mail_from_to_settings.rb b/db/migrate/20260208151854_add_mail_from_to_settings.rb new file mode 100644 index 0000000..c8f51c4 --- /dev/null +++ b/db/migrate/20260208151854_add_mail_from_to_settings.rb @@ -0,0 +1,5 @@ +class AddMailFromToSettings < ActiveRecord::Migration[8.2] + def change + add_column :settings, :mail_from, :string + end +end diff --git a/db/migrate/20260210184614_create_mobility_translations.rb b/db/migrate/20260210184614_create_mobility_translations.rb new file mode 100644 index 0000000..cd72cb3 --- /dev/null +++ b/db/migrate/20260210184614_create_mobility_translations.rb @@ -0,0 +1,40 @@ +class CreateMobilityTranslations < ActiveRecord::Migration[8.2] + def change + create_table :mobility_string_translations, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.string :locale, null: false + t.string :key, null: false + t.string :value + t.string :translatable_id, null: false + t.string :translatable_type, null: false + t.timestamps + end + + add_index :mobility_string_translations, + [ :translatable_id, :translatable_type, :locale, :key ], + unique: true, + name: :index_mobility_string_translations_on_keys + add_index :mobility_string_translations, + [ :translatable_id, :translatable_type, :key ], + name: :index_mobility_string_translations_on_translatable_attribute + add_index :mobility_string_translations, + [ :translatable_type, :key, :value, :locale ], + name: :index_mobility_string_translations_on_query_keys + + create_table :mobility_text_translations, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.string :locale, null: false + t.string :key, null: false + t.text :value + t.string :translatable_id, null: false + t.string :translatable_type, null: false + t.timestamps + end + + add_index :mobility_text_translations, + [ :translatable_id, :translatable_type, :locale, :key ], + unique: true, + name: :index_mobility_text_translations_on_keys + add_index :mobility_text_translations, + [ :translatable_id, :translatable_type, :key ], + name: :index_mobility_text_translations_on_translatable_attribute + end +end diff --git a/db/migrate/20260210184615_create_languages.rb b/db/migrate/20260210184615_create_languages.rb new file mode 100644 index 0000000..4de5e3f --- /dev/null +++ b/db/migrate/20260210184615_create_languages.rb @@ -0,0 +1,13 @@ +class CreateLanguages < ActiveRecord::Migration[8.2] + def change + create_table :languages, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.string :code, null: false + t.string :name, null: false + t.string :native_name, null: false + t.boolean :enabled, default: true, null: false + t.timestamps + end + + add_index :languages, :code, unique: true + end +end diff --git a/db/migrate/20260210184616_create_team_languages.rb b/db/migrate/20260210184616_create_team_languages.rb new file mode 100644 index 0000000..af55456 --- /dev/null +++ b/db/migrate/20260210184616_create_team_languages.rb @@ -0,0 +1,12 @@ +class CreateTeamLanguages < ActiveRecord::Migration[8.2] + def change + create_table :team_languages, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.references :team, null: false, foreign_key: true, type: :string + t.references :language, null: false, foreign_key: true, type: :string + t.boolean :active, default: true, null: false + t.timestamps + end + + add_index :team_languages, [ :team_id, :language_id ], unique: true + end +end diff --git a/db/migrate/20260210184617_create_articles.rb b/db/migrate/20260210184617_create_articles.rb new file mode 100644 index 0000000..79cf4bf --- /dev/null +++ b/db/migrate/20260210184617_create_articles.rb @@ -0,0 +1,11 @@ +class CreateArticles < ActiveRecord::Migration[8.2] + def change + create_table :articles, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + t.references :team, null: false, foreign_key: true, type: :string + t.references :user, null: false, foreign_key: true, type: :string + t.string :title, null: false + t.text :body + t.timestamps + end + end +end diff --git a/db/migrate/20260211174628_add_locale_to_users.rb b/db/migrate/20260211174628_add_locale_to_users.rb new file mode 100644 index 0000000..ff96b41 --- /dev/null +++ b/db/migrate/20260211174628_add_locale_to_users.rb @@ -0,0 +1,5 @@ +class AddLocaleToUsers < ActiveRecord::Migration[8.2] + def change + add_column :users, :locale, :string + end +end diff --git a/db/migrate/20260401205319_remove_not_null_from_articles_title.rb b/db/migrate/20260401205319_remove_not_null_from_articles_title.rb new file mode 100644 index 0000000..c1f290a --- /dev/null +++ b/db/migrate/20260401205319_remove_not_null_from_articles_title.rb @@ -0,0 +1,5 @@ +class RemoveNotNullFromArticlesTitle < ActiveRecord::Migration[8.2] + def change + change_column_null :articles, :title, true + end +end diff --git a/db/migrate/20260406182149_add_purpose_to_chats.rb b/db/migrate/20260406182149_add_purpose_to_chats.rb new file mode 100644 index 0000000..003646c --- /dev/null +++ b/db/migrate/20260406182149_add_purpose_to_chats.rb @@ -0,0 +1,6 @@ +class AddPurposeToChats < ActiveRecord::Migration[8.0] + def change + add_column :chats, :purpose, :string, default: "conversation" + add_index :chats, :purpose + end +end diff --git a/db/migrate/20260408160000_add_github_and_ai_model_to_settings.rb b/db/migrate/20260408160000_add_github_and_ai_model_to_settings.rb new file mode 100644 index 0000000..9017915 --- /dev/null +++ b/db/migrate/20260408160000_add_github_and_ai_model_to_settings.rb @@ -0,0 +1,10 @@ +class AddGithubAndAiModelToSettings < ActiveRecord::Migration[8.2] + def change + add_column :settings, :default_ai_model, :string + add_column :settings, :github_whyruby_client_id, :string + add_column :settings, :github_whyruby_client_secret, :string + add_column :settings, :github_rubycommunity_client_id, :string + add_column :settings, :github_rubycommunity_client_secret, :string + add_column :settings, :github_api_token, :string + end +end diff --git a/db/migrate/20260408170000_add_per_task_ai_models_to_settings.rb b/db/migrate/20260408170000_add_per_task_ai_models_to_settings.rb new file mode 100644 index 0000000..c0a21f7 --- /dev/null +++ b/db/migrate/20260408170000_add_per_task_ai_models_to_settings.rb @@ -0,0 +1,8 @@ +class AddPerTaskAiModelsToSettings < ActiveRecord::Migration[8.2] + def change + add_column :settings, :summary_model, :string + add_column :settings, :testimonial_model, :string + add_column :settings, :validation_model, :string + add_column :settings, :translation_model, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 2e2ef68..5532af8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_02_12_163246) do +ActiveRecord::Schema[8.2].define(version: 2026_04_08_170000) do create_table "_litestream_lock", id: false, force: :cascade do |t| t.integer "id" end @@ -49,14 +49,20 @@ create_table "admins", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.string "email", default: "", null: false - t.string "encrypted_password", default: "", null: false - t.datetime "remember_created_at" - t.datetime "reset_password_sent_at" - t.string "reset_password_token" + t.string "email" t.datetime "updated_at", null: false t.index ["email"], name: "index_admins_on_email", unique: true - t.index ["reset_password_token"], name: "index_admins_on_reset_password_token", unique: true + end + + create_table "articles", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.text "body" + t.datetime "created_at", null: false + t.string "team_id", null: false + t.string "title" + t.datetime "updated_at", null: false + t.string "user_id", null: false + t.index ["team_id"], name: "index_articles_on_team_id" + t.index ["user_id"], name: "index_articles_on_user_id" end create_table "categories", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| @@ -73,6 +79,21 @@ t.index ["slug"], name: "index_categories_on_slug" end + create_table "chats", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "messages_count", default: 0, null: false + t.string "model_id" + t.string "purpose", default: "conversation" + t.string "team_id" + t.decimal "total_cost", precision: 12, scale: 6, default: "0.0", null: false + t.datetime "updated_at", null: false + t.string "user_id", null: false + t.index ["model_id"], name: "index_chats_on_model_id" + t.index ["purpose"], name: "index_chats_on_purpose" + t.index ["team_id"], name: "index_chats_on_team_id" + t.index ["user_id"], name: "index_chats_on_user_id" + end + create_table "comments", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| t.text "body", null: false t.datetime "created_at", null: false @@ -97,6 +118,99 @@ t.index ["sluggable_type", "sluggable_id"], name: "index_friendly_id_slugs_on_sluggable_type_and_sluggable_id" end + create_table "languages", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.string "code", null: false + t.datetime "created_at", null: false + t.boolean "enabled", default: true, null: false + t.string "name", null: false + t.string "native_name", null: false + t.datetime "updated_at", null: false + t.index ["code"], name: "index_languages_on_code", unique: true + end + + create_table "memberships", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "invited_by_id" + t.string "role", default: "member", null: false + t.string "team_id", null: false + t.datetime "updated_at", null: false + t.string "user_id", null: false + t.index ["invited_by_id"], name: "index_memberships_on_invited_by_id" + t.index ["team_id"], name: "index_memberships_on_team_id" + t.index ["user_id", "team_id"], name: "index_memberships_on_user_id_and_team_id", unique: true + t.index ["user_id"], name: "index_memberships_on_user_id" + end + + create_table "messages", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.integer "cache_creation_tokens" + t.integer "cached_tokens" + t.string "chat_id", null: false + t.text "content" + t.json "content_raw" + t.decimal "cost", precision: 10, scale: 6, default: "0.0" + t.datetime "created_at", null: false + t.integer "input_tokens" + t.string "model_id" + t.integer "output_tokens" + t.string "role", null: false + t.text "thinking_signature" + t.text "thinking_text" + t.integer "thinking_tokens" + t.string "tool_call_id" + t.datetime "updated_at", null: false + t.index ["chat_id"], name: "index_messages_on_chat_id" + t.index ["model_id"], name: "index_messages_on_model_id" + t.index ["role"], name: "index_messages_on_role" + t.index ["tool_call_id"], name: "index_messages_on_tool_call_id" + end + + create_table "mobility_string_translations", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "key", null: false + t.string "locale", null: false + t.string "translatable_id", null: false + t.string "translatable_type", null: false + t.datetime "updated_at", null: false + t.string "value" + t.index ["translatable_id", "translatable_type", "key"], name: "index_mobility_string_translations_on_translatable_attribute" + t.index ["translatable_id", "translatable_type", "locale", "key"], name: "index_mobility_string_translations_on_keys", unique: true + t.index ["translatable_type", "key", "value", "locale"], name: "index_mobility_string_translations_on_query_keys" + end + + create_table "mobility_text_translations", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "key", null: false + t.string "locale", null: false + t.string "translatable_id", null: false + t.string "translatable_type", null: false + t.datetime "updated_at", null: false + t.text "value" + t.index ["translatable_id", "translatable_type", "key"], name: "index_mobility_text_translations_on_translatable_attribute" + t.index ["translatable_id", "translatable_type", "locale", "key"], name: "index_mobility_text_translations_on_keys", unique: true + end + + create_table "models", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.json "capabilities", default: [] + t.integer "chats_count", default: 0, null: false + t.integer "context_window" + t.datetime "created_at", null: false + t.string "family" + t.date "knowledge_cutoff" + t.integer "max_output_tokens" + t.json "metadata", default: {} + t.json "modalities", default: {} + t.datetime "model_created_at" + t.string "model_id", null: false + t.string "name", null: false + t.json "pricing", default: {} + t.string "provider", null: false + t.decimal "total_cost", precision: 12, scale: 6, default: "0.0", null: false + t.datetime "updated_at", null: false + t.index ["family"], name: "index_models_on_family" + t.index ["provider", "model_id"], name: "index_models_on_provider_and_model_id", unique: true + t.index ["provider"], name: "index_models_on_provider" + end + create_table "posts", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| t.string "category_id", null: false t.integer "comments_count", default: 0, null: false @@ -154,6 +268,15 @@ t.index ["user_id"], name: "index_projects_on_user_id" end + create_table "provider_credentials", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "key", null: false + t.string "provider", null: false + t.datetime "updated_at", null: false + t.string "value" + t.index ["provider", "key"], name: "index_provider_credentials_on_provider_and_key", unique: true + end + create_table "reports", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| t.datetime "created_at", null: false t.text "description" @@ -166,6 +289,33 @@ t.index ["user_id"], name: "index_reports_on_user_id" end + create_table "settings", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "default_ai_model" + t.string "github_api_token" + t.string "github_rubycommunity_client_id" + t.string "github_rubycommunity_client_secret" + t.string "github_whyruby_client_id" + t.string "github_whyruby_client_secret" + t.string "litestream_replica_access_key" + t.string "litestream_replica_bucket" + t.string "litestream_replica_key_id" + t.string "mail_from" + t.boolean "public_chats", default: true, null: false + t.string "smtp_address" + t.string "smtp_password" + t.string "smtp_username" + t.string "stripe_publishable_key" + t.string "stripe_secret_key" + t.string "stripe_webhook_secret" + t.string "summary_model" + t.string "testimonial_model" + t.string "translation_model" + t.integer "trial_days", default: 30 + t.datetime "updated_at", null: false + t.string "validation_model" + end + create_table "star_snapshots", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| t.datetime "created_at", null: false t.string "project_id", null: false @@ -185,6 +335,34 @@ t.index ["slug"], name: "index_tags_on_slug" end + create_table "team_languages", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.string "language_id", null: false + t.string "team_id", null: false + t.datetime "updated_at", null: false + t.index ["language_id"], name: "index_team_languages_on_language_id" + t.index ["team_id", "language_id"], name: "index_team_languages_on_team_id_and_language_id", unique: true + t.index ["team_id"], name: "index_team_languages_on_team_id" + end + + create_table "teams", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.string "api_key", null: false + t.boolean "cancel_at_period_end", default: false, null: false + t.datetime "created_at", null: false + t.datetime "current_period_ends_at" + t.string "name", null: false + t.string "slug", null: false + t.string "stripe_customer_id" + t.string "stripe_subscription_id" + t.string "subscription_status" + t.datetime "updated_at", null: false + t.index ["api_key"], name: "index_teams_on_api_key", unique: true + t.index ["slug"], name: "index_teams_on_slug", unique: true + t.index ["stripe_customer_id"], name: "index_teams_on_stripe_customer_id", unique: true + t.index ["stripe_subscription_id"], name: "index_teams_on_stripe_subscription_id", unique: true + end + create_table "testimonials", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| t.integer "ai_attempts", default: 0 t.text "ai_feedback" @@ -204,6 +382,19 @@ t.index ["user_id"], name: "index_testimonials_on_user_id", unique: true end + create_table "tool_calls", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| + t.json "arguments", default: {} + t.datetime "created_at", null: false + t.string "message_id", null: false + t.string "name", null: false + t.string "thought_signature" + t.string "tool_call_id", null: false + t.datetime "updated_at", null: false + t.index ["message_id"], name: "index_tool_calls_on_message_id" + t.index ["name"], name: "index_tool_calls_on_name" + t.index ["tool_call_id"], name: "index_tool_calls_on_tool_call_id", unique: true + end + create_table "users", id: :string, default: -> { "uuid7()" }, force: :cascade do |t| t.string "avatar_url" t.text "bio" @@ -220,6 +411,7 @@ t.text "hidden_repos" t.float "latitude" t.string "linkedin" + t.string "locale" t.string "location" t.float "longitude" t.string "name" @@ -234,6 +426,7 @@ t.string "slug" t.integer "stars_gained", default: 0, null: false t.string "timezone" + t.decimal "total_cost", precision: 12, scale: 6, default: "0.0", null: false t.string "twitter" t.boolean "unsubscribed_from_newsletter", default: false, null: false t.datetime "updated_at", null: false @@ -250,8 +443,18 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "articles", "teams" + add_foreign_key "articles", "users" + add_foreign_key "chats", "models" + add_foreign_key "chats", "teams" + add_foreign_key "chats", "users" add_foreign_key "comments", "posts" add_foreign_key "comments", "users" + add_foreign_key "memberships", "teams" + add_foreign_key "memberships", "users" + add_foreign_key "memberships", "users", column: "invited_by_id" + add_foreign_key "messages", "chats" + add_foreign_key "messages", "models" add_foreign_key "posts", "categories" add_foreign_key "posts", "users" add_foreign_key "posts_tags", "posts" @@ -260,4 +463,7 @@ add_foreign_key "reports", "posts" add_foreign_key "reports", "users" add_foreign_key "star_snapshots", "projects" + add_foreign_key "team_languages", "languages" + add_foreign_key "team_languages", "teams" + add_foreign_key "tool_calls", "messages" end diff --git a/docs/superpowers/plans/2026-04-06-37signals-refactor-plan.md b/docs/superpowers/plans/2026-04-06-37signals-refactor-plan.md new file mode 100644 index 0000000..1971ddf --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-37signals-refactor-plan.md @@ -0,0 +1,2323 @@ +# 37signals Style Refactor + RubyLLM Migration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate all service objects (moving logic to model concerns), migrate AI jobs from raw OpenAI/Anthropic to RubyLLM with per-user cost tracking, and update AGENTS.md. + +**Architecture:** Services become model concerns (adjective-named). AI operations create system Chat/Message records via RubyLLM's `acts_as_chat` for automatic token/cost tracking. Jobs become thin delegators to model methods. + +**Tech Stack:** Rails 8.2, RubyLLM (~> 1.9), SQLite, Minitest, WebMock + +--- + +## Task 1: User::Geocodable — LocationNormalizer + TimezoneResolver → concern + +**Files:** +- Create: `app/models/concerns/user/geocodable.rb` +- Modify: `app/models/user.rb` +- Modify: `app/jobs/normalize_location_job.rb` +- Modify: `test/jobs/normalize_location_job_test.rb` +- Move: `test/services/location_normalizer_test.rb` → `test/models/concerns/user/geocodable_test.rb` +- Move: `test/services/timezone_resolver_test.rb` → merged into above +- Delete: `app/services/location_normalizer.rb` +- Delete: `app/services/timezone_resolver.rb` + +- [ ] **Step 1: Create the concern** + +```ruby +# app/models/concerns/user/geocodable.rb +# frozen_string_literal: true + +require "net/http" +require "json" + +module User::Geocodable + extend ActiveSupport::Concern + + PHOTON_API = "https://photon.komoot.io/api/" + + # Legacy IANA identifiers renamed in recent tzdata releases. + LEGACY_TIMEZONES = { + "Europe/Kiev" => "Europe/Kyiv" + }.freeze + + GeoResult = Data.define(:city, :state, :country_code, :latitude, :longitude) + + # Geocode user location string, setting lat/lng/normalized_location/timezone + def geocode! + result = photon_search(location) + + unless result + update_columns(normalized_location: nil, latitude: nil, longitude: nil, timezone: nil) + return + end + + normalized_string = build_normalized_string(result) + + unless normalized_string + update_columns(normalized_location: nil, latitude: nil, longitude: nil, timezone: nil) + return + end + + timezone = resolve_timezone(result.latitude, result.longitude) + + update_columns( + normalized_location: normalized_string, + latitude: result.latitude, + longitude: result.longitude, + timezone: timezone + ) + end + + private + + def photon_search(query) + return nil if query.blank? + + # Skip strings that are clearly not geographic (pure emoji, etc.) + stripped = query.gsub(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/, "").strip + return nil if stripped.empty? + + uri = URI(PHOTON_API) + uri.query = URI.encode_www_form(q: query, limit: 1) + + response = Net::HTTP.get_response(uri) + return nil unless response.is_a?(Net::HTTPSuccess) + + data = JSON.parse(response.body) + feature = data.dig("features", 0) + return nil unless feature + + properties = feature["properties"] + return nil unless properties + + coordinates = feature.dig("geometry", "coordinates") + lon, lat = coordinates if coordinates.is_a?(Array) && coordinates.size >= 2 + + GeoResult.new( + city: properties["city"] || (properties["type"] == "city" ? properties["name"] : nil), + state: properties["state"], + country_code: properties["countrycode"], + latitude: lat&.to_f, + longitude: lon&.to_f + ) + rescue StandardError => e + Rails.logger.warn "Photon geocoding failed: #{e.message}" + nil + end + + def build_normalized_string(result) + country_code = result.country_code&.upcase + return nil unless country_code.present? + + if result.city.present? + "#{result.city}, #{country_code}" + elsif result.state.present? + "#{result.state}, #{country_code}" + else + country_code + end + end + + def resolve_timezone(latitude, longitude) + return "Etc/UTC" if latitude.nil? || longitude.nil? + + result = WhereTZ.lookup(latitude, longitude) + timezone = result.is_a?(Array) ? result.first : result + normalize_timezone(timezone) + rescue StandardError => e + Rails.logger.warn "Timezone lookup failed for (#{latitude}, #{longitude}): #{e.message}" + "Etc/UTC" + end + + def normalize_timezone(timezone) + return "Etc/UTC" if timezone.blank? + + normalized = LEGACY_TIMEZONES[timezone] || timezone + TZInfo::Timezone.get(normalized) + normalized + rescue TZInfo::InvalidTimezoneIdentifier + Rails.logger.warn "Unknown timezone identifier: #{timezone}, falling back to Etc/UTC" + "Etc/UTC" + end +end +``` + +- [ ] **Step 2: Include concern in User model** + +In `app/models/user.rb`, add after the `Costable` include: + +```ruby +include User::Geocodable +``` + +- [ ] **Step 3: Thin out the job** + +Replace `app/jobs/normalize_location_job.rb`: + +```ruby +# frozen_string_literal: true + +class NormalizeLocationJob < ApplicationJob + queue_as :default + + def perform(user_id) + user = User.find_by(id: user_id) + user&.geocode! + end +end +``` + +- [ ] **Step 4: Move and merge tests** + +Create `test/models/concerns/user/geocodable_test.rb` combining both service tests: + +```ruby +# frozen_string_literal: true + +require "test_helper" +require "webmock/minitest" + +class User::GeocodableTest < ActiveSupport::TestCase + setup do + WebMock.disable_net_connect!(allow_localhost: true) + @user = users(:user_with_testimonial) + end + + teardown do + WebMock.allow_net_connect! + end + + # --- Geocoding --- + + test "geocode! sets normalized_location, coordinates, and timezone" do + @user.update_columns(location: "NYC") + stub_photon("NYC", city: "New York", countrycode: "us", lon: -74.006, lat: 40.7128) + + @user.geocode! + @user.reload + + assert_equal "New York, US", @user.normalized_location + assert_in_delta 40.7128, @user.latitude, 0.001 + assert_in_delta(-74.006, @user.longitude, 0.001) + assert_equal "America/New_York", @user.timezone + end + + test "geocode! clears fields when geocoding fails" do + @user.update_columns(location: "Universe", normalized_location: "Old, US", latitude: 1.0, longitude: 1.0, timezone: "America/New_York") + stub_photon_empty("Universe") + + @user.geocode! + @user.reload + + assert_nil @user.normalized_location + assert_nil @user.latitude + assert_nil @user.longitude + assert_nil @user.timezone + end + + test "geocode! returns nil for blank location" do + @user.update_columns(location: nil) + + @user.geocode! + @user.reload + + assert_nil @user.normalized_location + end + + test "geocode! returns nil for pure emoji location" do + @user.update_columns(location: "\u{1F30D}") + + @user.geocode! + @user.reload + + assert_nil @user.normalized_location + end + + test "geocode! returns State, CC when no city available" do + @user.update_columns(location: "California") + stub_photon("California", city: nil, countrycode: "us", state: "California", lon: -119.417, lat: 36.778) + + @user.geocode! + @user.reload + + assert_equal "California, US", @user.normalized_location + end + + test "geocode! returns just CC when only country code available" do + @user.update_columns(location: "Germany") + stub_photon("Germany", city: nil, countrycode: "de", state: nil, lon: 10.451, lat: 51.165) + + @user.geocode! + @user.reload + + assert_equal "DE", @user.normalized_location + end + + test "geocode! clears fields when result has no country code" do + @user.update_columns(location: "Somewhere") + stub_photon("Somewhere", city: "Somewhere", countrycode: nil, state: nil, lon: 0.0, lat: 0.0) + + @user.geocode! + @user.reload + + assert_nil @user.normalized_location + end + + # --- Timezone --- + + test "geocode! resolves Berlin coordinates to Europe/Berlin" do + @user.update_columns(location: "Berlin") + stub_photon("Berlin", city: "Berlin", countrycode: "de", lon: 13.405, lat: 52.52) + + @user.geocode! + @user.reload + + assert_equal "Europe/Berlin", @user.timezone + end + + test "geocode! resolves Kyiv coordinates to canonical Europe/Kyiv" do + @user.update_columns(location: "Kyiv") + stub_photon("Kyiv", city: "Kyiv", countrycode: "ua", lon: 30.52, lat: 50.45) + + @user.geocode! + @user.reload + + assert_equal "Europe/Kyiv", @user.timezone + end + + private + + def stub_photon(query, city:, countrycode:, state: nil, lon: 0.0, lat: 0.0) + response = { + type: "FeatureCollection", + features: [ { + type: "Feature", + geometry: { type: "Point", coordinates: [ lon, lat ] }, + properties: { city: city, countrycode: countrycode, state: state } + } ] + } + + stub_request(:get, "https://photon.komoot.io/api/") + .with(query: { q: query, limit: "1" }) + .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) + end + + def stub_photon_empty(query) + response = { type: "FeatureCollection", features: [] } + + stub_request(:get, "https://photon.komoot.io/api/") + .with(query: { q: query, limit: "1" }) + .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) + end +end +``` + +- [ ] **Step 5: Update NormalizeLocationJob test** + +Replace `test/jobs/normalize_location_job_test.rb`: + +```ruby +# frozen_string_literal: true + +require "test_helper" +require "webmock/minitest" + +class NormalizeLocationJobTest < ActiveJob::TestCase + setup do + WebMock.disable_net_connect!(allow_localhost: true) + end + + teardown do + WebMock.allow_net_connect! + end + + test "delegates to user.geocode!" do + user = users(:user_with_testimonial) + user.update_columns(location: "NYC") + + stub_photon("NYC", city: "New York", countrycode: "us", lon: -74.006, lat: 40.7128) + + NormalizeLocationJob.perform_now(user.id) + + user.reload + assert_equal "New York, US", user.normalized_location + end + + test "handles non-existent user gracefully" do + assert_nothing_raised do + NormalizeLocationJob.perform_now("nonexistent-id") + end + end + + private + + def stub_photon(query, city:, countrycode:, lon: 0.0, lat: 0.0) + response = { + type: "FeatureCollection", + features: [ { + type: "Feature", + geometry: { type: "Point", coordinates: [ lon, lat ] }, + properties: { city: city, countrycode: countrycode } + } ] + } + + stub_request(:get, "https://photon.komoot.io/api/") + .with(query: { q: query, limit: "1" }) + .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) + end +end +``` + +- [ ] **Step 6: Delete old service files and tests** + +```bash +rm app/services/location_normalizer.rb +rm app/services/timezone_resolver.rb +rm test/services/location_normalizer_test.rb +rm test/services/timezone_resolver_test.rb +``` + +- [ ] **Step 7: Run tests** + +Run: `rails test test/models/concerns/user/geocodable_test.rb test/jobs/normalize_location_job_test.rb` +Expected: All pass + +- [ ] **Step 8: Run full test suite** + +Run: `rails test` +Expected: All pass + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "refactor: move LocationNormalizer + TimezoneResolver to User::Geocodable concern" +``` + +--- + +## Task 2: Post::SvgSanitizable — SvgSanitizer → concern + +**Files:** +- Create: `app/models/concerns/post/svg_sanitizable.rb` +- Modify: `app/models/post.rb` +- Delete: `app/services/svg_sanitizer.rb` + +- [ ] **Step 1: Create the concern** + +```ruby +# app/models/concerns/post/svg_sanitizable.rb +module Post::SvgSanitizable + extend ActiveSupport::Concern + + # Allowed SVG elements + ALLOWED_SVG_ELEMENTS = %w[ + svg g path rect circle ellipse line polyline polygon text tspan textPath + defs pattern clipPath mask linearGradient radialGradient stop symbol use + image desc title metadata + ].freeze + + # Allowed attributes (no event handlers) + ALLOWED_SVG_ATTRIBUTES = %w[ + style class + viewbox preserveaspectratio + x y x1 y1 x2 y2 cx cy r rx ry + d points fill stroke stroke-width stroke-linecap stroke-linejoin + fill-opacity stroke-opacity opacity + transform translate rotate scale + font-family font-size font-weight text-anchor + href xlink:href + offset stop-color stop-opacity + gradientunits gradienttransform + patternunits patterntransform + clip-path mask + xmlns xmlns:xlink version + ].map(&:downcase).freeze + + DANGEROUS_SVG_PATTERNS = [ + /]/i, + /<\/script>/i, + /javascript:/i, + /on\w+\s*=/i, + /data:text\/html/i, + /vbscript:/i, + /behavior:/i, + /expression\(/i, + /-moz-binding:/i + ].freeze + + # Sanitize the logo_svg attribute in place + def sanitize_logo_svg! + return if logo_svg.blank? + + self.logo_svg = self.class.sanitize_svg(logo_svg) + end + + class_methods do + def sanitize_svg(svg_content) + return "" if svg_content.blank? + + svg_content = fix_svg_case_sensitivity(svg_content) + + DANGEROUS_SVG_PATTERNS.each do |pattern| + svg_content = svg_content.gsub(pattern, "") + end + + begin + doc = Nokogiri::XML::DocumentFragment.parse(svg_content) do |config| + config.nonet + config.noent + end + rescue => e + Rails.logger.error "Failed to parse SVG: #{e.message}" + return "" + end + + svg_element = if doc.children.any? { |c| c.name.downcase == "svg" } + doc.children.find { |c| c.name.downcase == "svg" } + else + doc.at_css("svg") || doc.at_xpath("//svg") + end + + return "" unless svg_element + + svg_element.css("*").each do |element| + unless ALLOWED_SVG_ELEMENTS.include?(element.name.downcase) + element.remove + next + end + + element.attributes.keys.each do |name| + unless ALLOWED_SVG_ATTRIBUTES.include?(name.downcase) + element.remove_attribute(name) + end + end + + if element["style"]&.match?(/javascript:|expression\(|behavior:|binding:|@import/i) + element.remove_attribute("style") + end + + %w[href xlink:href].each do |attr| + if element[attr]&.match?(/^javascript:/i) + element.remove_attribute(attr) + end + end + end + + original_width = svg_element["width"] + original_height = svg_element["height"] + + svg_element.attributes.keys.each do |name| + unless ALLOWED_SVG_ATTRIBUTES.include?(name.downcase) + svg_element.remove_attribute(name) + end + end + + if svg_element["viewBox"].blank? && svg_element["viewbox"].blank? + if original_width && original_height + width_val = original_width.to_s.gsub(/[^\d.]/, "").to_f + height_val = original_height.to_s.gsub(/[^\d.]/, "").to_f + + if width_val > 0 && height_val > 0 + svg_element["viewBox"] = "0 0 #{width_val} #{height_val}" + end + end + end + + svg_element.to_xml + end + + private + + def fix_svg_case_sensitivity(svg_content) + fixed = svg_content.dup + fixed.gsub!(/\bviewbox=/i, "viewBox=") + fixed.gsub!(/\bpreserveaspectratio=/i, "preserveAspectRatio=") + fixed.gsub!(/\bgradientunits=/i, "gradientUnits=") + fixed.gsub!(/\bgradienttransform=/i, "gradientTransform=") + fixed.gsub!(/\bpatternunits=/i, "patternUnits=") + fixed.gsub!(/\bpatterntransform=/i, "patternTransform=") + fixed.gsub!(/\bclippath=/i, "clipPath=") + fixed.gsub!(/\btextlength=/i, "textLength=") + fixed.gsub!(/\blengthadjust=/i, "lengthAdjust=") + fixed.gsub!(/\bbaseprofile=/i, "baseProfile=") + fixed.gsub!(/\bmarkerwidth=/i, "markerWidth=") + fixed.gsub!(/\bmarkerheight=/i, "markerHeight=") + fixed.gsub!(/\bmarkerunits=/i, "markerUnits=") + fixed.gsub!(/\brefx=/i, "refX=") + fixed.gsub!(/\brefy=/i, "refY=") + fixed.gsub!(/\bpathlength=/i, "pathLength=") + fixed.gsub!(/\bstrokedasharray=/i, "strokeDasharray=") + fixed.gsub!(/\bstrokedashoffset=/i, "strokeDashoffset=") + fixed.gsub!(/\bstrokelinecap=/i, "strokeLinecap=") + fixed.gsub!(/\bstrokelinejoin=/i, "strokeLinejoin=") + fixed.gsub!(/\bstrokemiterlimit=/i, "strokeMiterlimit=") + fixed + end + end +end +``` + +- [ ] **Step 2: Include concern in Post model and update callback** + +In `app/models/post.rb`, add near the top: + +```ruby +include Post::SvgSanitizable +``` + +Change the `clean_logo_svg` private method from: + +```ruby +def clean_logo_svg + return unless logo_svg.present? + self.logo_svg = SvgSanitizer.sanitize(logo_svg) +end +``` + +to: + +```ruby +def clean_logo_svg + sanitize_logo_svg! +end +``` + +- [ ] **Step 3: Delete old service** + +```bash +rm app/services/svg_sanitizer.rb +``` + +- [ ] **Step 4: Run tests** + +Run: `rails test` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor: move SvgSanitizer to Post::SvgSanitizable concern" +``` + +--- + +## Task 3: Post::MetadataFetchable — MetadataFetcher → concern + +**Files:** +- Create: `app/models/concerns/post/metadata_fetchable.rb` +- Modify: `app/models/post.rb` +- Modify: `app/controllers/posts_controller.rb` +- Delete: `app/services/metadata_fetcher.rb` + +- [ ] **Step 1: Create the concern** + +```ruby +# app/models/concerns/post/metadata_fetchable.rb +module Post::MetadataFetchable + extend ActiveSupport::Concern + + MAX_REDIRECTS = 3 + DEFAULT_TIMEOUT = 10 + DEFAULT_RETRIES = 1 + + # Fetch OpenGraph metadata from url + # Returns hash: { title:, description:, image_url:, parsed: } + def fetch_metadata!(options = {}) + return {} if url.blank? + + connection_timeout = options[:connection_timeout] || DEFAULT_TIMEOUT + read_timeout = options[:read_timeout] || DEFAULT_TIMEOUT + retries = options[:retries] || DEFAULT_RETRIES + allow_redirections = options[:allow_redirections] + + html = fetch_html_with_retries(url, connection_timeout, read_timeout, retries) + return {} unless html + + parsed = Nokogiri::HTML(html) + + { + title: best_title(parsed), + description: best_description(parsed), + image_url: best_image(parsed), + parsed: parsed + } + rescue => e + Rails.logger.error "Failed to fetch metadata from #{url}: #{e.message}" + {} + end + + # Fetch page text content (for AI summarization of link posts) + def fetch_external_content + return nil if url.blank? + + html = fetch_html_with_retries(url, 5, 5, 1) + return nil unless html + + parsed = Nokogiri::HTML(html) + + content_parts = [] + + title_text = best_title(parsed) + content_parts << "Title: #{title_text}" if title_text.present? + + desc_text = best_description(parsed) + content_parts << "Description: #{desc_text}" if desc_text.present? + + main_content = extract_main_content(parsed) + content_parts << main_content if main_content.present? + + if content_parts.length <= 2 + raw_text = parsed.css("body").text.squish rescue nil + content_parts << raw_text if raw_text.present? + end + + result = content_parts.join("\n\n") + result.presence + rescue => e + Rails.logger.error "Failed to fetch external content from #{url}: #{e.message}" + nil + end + + private + + def fetch_html_with_retries(target_url, connection_timeout, read_timeout, retries) + attempts = 0 + begin + attempts += 1 + fetch_html(target_url, connection_timeout, read_timeout) + rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT => e + if attempts <= retries + retry + else + Rails.logger.error "Failed to fetch #{target_url} after #{retries} retries: #{e.message}" + nil + end + rescue => e + Rails.logger.error "Error fetching #{target_url}: #{e.message}" + nil + end + end + + def fetch_html(target_url, connection_timeout, read_timeout, redirect_count = 0) + uri = URI(target_url) + return nil unless %w[http https].include?(uri.scheme&.downcase) + + request = Net::HTTP::Get.new(uri) + request["User-Agent"] = "Ruby/#{RUBY_VERSION} (WhyRuby.info)" + request["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + + response = Net::HTTP.start( + uri.hostname, uri.port, + use_ssl: uri.scheme == "https", + open_timeout: connection_timeout, + read_timeout: read_timeout + ) { |http| http.request(request) } + + case response + when Net::HTTPSuccess + response.body + when Net::HTTPRedirection + return nil if redirect_count >= MAX_REDIRECTS + location = response["location"] + return nil unless location + redirect_uri = URI.join(uri.to_s, location) + fetch_html(redirect_uri.to_s, connection_timeout, read_timeout, redirect_count + 1) + else + nil + end + end + + def best_title(parsed) + extract_meta(parsed, property: "og:title") || + extract_meta(parsed, name: "twitter:title") || + parsed.at_css("title")&.text&.strip || + parsed.at_css("h1")&.text&.strip + end + + def best_description(parsed) + extract_meta(parsed, property: "og:description") || + extract_meta(parsed, name: "twitter:description") || + extract_meta(parsed, name: "description") || + extract_first_paragraph(parsed) + end + + def best_image(parsed) + og_image = extract_meta(parsed, property: "og:image") + return resolve_metadata_url(og_image) if og_image + + twitter_image = extract_meta(parsed, name: "twitter:image") + return resolve_metadata_url(twitter_image) if twitter_image + + largest = find_largest_image(parsed) + return resolve_metadata_url(largest) if largest + + first_image = parsed.at_css("img")&.[]("src") + resolve_metadata_url(first_image) if first_image + end + + def extract_meta(parsed, property: nil, name: nil) + if property + parsed.at_css("meta[property='#{property}']")&.[]("content")&.strip + elsif name + parsed.at_css("meta[name='#{name}']")&.[]("content")&.strip + end + end + + def extract_first_paragraph(parsed) + parsed.css("p").each do |p| + text = p.text.strip + return text if text.length > 50 + end + nil + end + + def extract_main_content(parsed) + %w[main article [role="main"] .content #content .post-content .entry-content .article-body].each do |selector| + element = parsed.at_css(selector) + if element + text = element.text.squish + return text if text.length > 100 + end + end + + paragraphs = parsed.css("p").map(&:text).reject(&:blank?) + paragraphs.join(" ").presence + end + + def find_largest_image(parsed) + largest = nil + max_size = 0 + + parsed.css("img").each do |img| + width = img["width"].to_i + height = img["height"].to_i + next if width.zero? || height.zero? || width < 200 || height < 200 + + aspect_ratio = width.to_f / height + next if aspect_ratio < 0.33 || aspect_ratio > 3.0 + + size = width * height + if size > max_size + max_size = size + largest = img["src"] + end + end + + largest + end + + def resolve_metadata_url(path) + return nil if path.blank? + return path if path.start_with?("http://", "https://") + + begin + URI.join(url, path).to_s + rescue => e + Rails.logger.warn "Failed to resolve relative URL #{path}: #{e.message}" + path + end + end +end +``` + +- [ ] **Step 2: Include concern in Post and update controller** + +In `app/models/post.rb`, add: + +```ruby +include Post::MetadataFetchable +``` + +In `app/controllers/posts_controller.rb`, change the `fetch_metadata` action from: + +```ruby +fetcher = MetadataFetcher.new(url) +result = fetcher.fetch! + +metadata = { + title: result[:title], + summary: result[:description], + image_url: result[:image_url] +} +``` + +to: + +```ruby +post = Post.new(url: url) +result = post.fetch_metadata! + +metadata = { + title: result[:title], + summary: result[:description], + image_url: result[:image_url] +} +``` + +- [ ] **Step 3: Delete old service** + +```bash +rm app/services/metadata_fetcher.rb +``` + +- [ ] **Step 4: Run tests** + +Run: `rails test` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor: move MetadataFetcher to Post::MetadataFetchable concern" +``` + +--- + +## Task 4: Post::ImageVariantable — ImageProcessor → concern + +**Files:** +- Create: `app/models/concerns/post/image_variantable.rb` +- Modify: `app/models/post.rb` +- Modify: `app/controllers/posts_controller.rb` +- Delete: `app/services/image_processor.rb` + +- [ ] **Step 1: Create the concern** + +```ruby +# app/models/concerns/post/image_variantable.rb +module Post::ImageVariantable + extend ActiveSupport::Concern + + ALLOWED_CONTENT_TYPES = %w[ + image/jpeg image/jpg image/png image/webp image/tiff image/x-tiff + ].freeze + + IMAGE_VARIANTS = { + tile: { width: 684, height: 384, quality: 92 }, + post: { width: 1664, height: 936, quality: 94 }, + og: { width: 1200, height: 630, quality: 95 } + }.freeze + + MAX_IMAGE_SIZE = 20.megabytes + + # Generate all WebP variants from featured_image + def process_image_variants! + return { error: "No image attached" } unless featured_image.attached? + return { error: "File too large" } if featured_image.blob.byte_size > MAX_IMAGE_SIZE + return { error: "Invalid file type" } unless ALLOWED_CONTENT_TYPES.include?(featured_image.blob.content_type) + + variants = {} + + featured_image.blob.open do |tempfile| + IMAGE_VARIANTS.each do |name, config| + variant_blob = generate_image_variant(tempfile.path, config) + variants[name] = variant_blob.id if variant_blob + end + end + + update_columns(image_variants: variants) + { success: true, variants: variants } + rescue => e + Rails.logger.error "Image processing error: #{e.message}" + { error: "Processing failed: #{e.message}" } + end + + def image_variant(size = :medium) + return nil unless featured_image.attached? && image_variants.present? + + variant_id = image_variants[size.to_s] + return featured_image.blob unless variant_id + + ActiveStorage::Blob.find_by(id: variant_id) || featured_image.blob + end + + def image_url_for_size(size = :medium) + blob = image_variant(size) + return nil unless blob + + Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true) + end + + def has_processed_images? + image_variants.present? + end + + def reprocess_image! + return unless featured_image.attached? + + process_image_variants! + end + + def clear_image_variants! + if image_variants.present? + image_variants.each do |_size, blob_id| + ActiveStorage::Blob.find_by(id: blob_id)&.purge_later + end + end + + update_columns(image_variants: nil) + end + + # Attach image from URL and process variants + def attach_image_from_url!(image_url) + return if image_url.blank? + + require "open-uri" + + image_io = URI.open(image_url, + "User-Agent" => "Ruby/#{RUBY_VERSION}", + read_timeout: 10, + open_timeout: 10 + ) + + return if image_io.size > MAX_IMAGE_SIZE + + temp_file = Tempfile.new([ "remote_image", File.extname(URI.parse(image_url).path) ]) + temp_file.binmode + temp_file.write(image_io.read) + temp_file.rewind + + featured_image.attach(io: temp_file, filename: File.basename(URI.parse(image_url).path)) + process_image_variants! + rescue => e + Rails.logger.error "Failed to fetch/process image from URL #{image_url}: #{e.message}" + ensure + temp_file&.close + temp_file&.unlink + end + + private + + def generate_image_variant(source_path, config) + variant_file = Tempfile.new([ "variant", ".webp" ]) + + begin + cmd = [ + "convert", source_path, + "-resize", "#{config[:width]}x#{config[:height]}>", + "-filter", "Lanczos", + "-quality", config[:quality].to_s, + "-define", "webp:lossless=false", + "-define", "webp:method=6", + "-define", "webp:alpha-quality=100", + "-define", "webp:image-hint=photo", + "-strip", + "webp:#{variant_file.path}" + ] + + unless system(*cmd, err: File::NULL) + Rails.logger.error "Failed to generate variant: #{config.inspect}" + return nil + end + + ActiveStorage::Blob.create_and_upload!( + io: File.open(variant_file.path), + filename: "variant_#{config[:width]}x#{config[:height]}.webp", + content_type: "image/webp" + ) + ensure + variant_file.close + variant_file.unlink + end + end + + def process_featured_image_if_needed + return unless featured_image.attached? + + should_process = !has_processed_images? || + (previous_changes.key?("updated_at") && featured_image.blob.created_at > 1.minute.ago) + + return unless should_process + + Rails.logger.info "Processing image for Post ##{id}" + result = process_image_variants! + + if result[:success] + Rails.logger.info "Successfully processed image for Post ##{id}" + else + Rails.logger.error "Failed to process image for Post ##{id}: #{result[:error]}" + end + end + + def featured_image_validation + return unless featured_image.attached? + + if featured_image.blob.byte_size > MAX_IMAGE_SIZE + errors.add(:featured_image, "is too large (maximum is #{MAX_IMAGE_SIZE / 1.megabyte}MB)") + end + + unless ALLOWED_CONTENT_TYPES.include?(featured_image.blob.content_type) + errors.add(:featured_image, "must be a JPEG, PNG, WebP, or TIFF image") + end + end +end +``` + +- [ ] **Step 2: Include concern in Post, remove duplicated methods and old references** + +In `app/models/post.rb`: +- Add `include Post::ImageVariantable` +- Remove the `MAX_IMAGE_SIZE` constant (now in concern) +- Remove the `image_variant`, `image_url_for_size`, `has_processed_images?`, `reprocess_image!`, `clear_image_variants!` methods (now in concern) +- Remove the `process_featured_image_if_needed` and `featured_image_validation` private methods (now in concern) +- The `after_commit :process_featured_image_if_needed` and `validate :featured_image_validation` callbacks remain — they now delegate to the concern + +- [ ] **Step 3: Update controller** + +In `app/controllers/posts_controller.rb`, change `fetch_and_attach_image_from_url`: + +```ruby +def fetch_and_attach_image_from_url(url) + return if url.blank? + @post.attach_image_from_url!(url) +end +``` + +- [ ] **Step 4: Delete old service** + +```bash +rm app/services/image_processor.rb +``` + +- [ ] **Step 5: Run tests** + +Run: `rails test` +Expected: All pass + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: move ImageProcessor to Post::ImageVariantable concern" +``` + +--- + +## Task 5: Post::OgImageGeneratable — SuccessStoryImageGenerator → concern + +**Files:** +- Create: `app/models/concerns/post/og_image_generatable.rb` +- Modify: `app/models/post.rb` +- Modify: `app/jobs/generate_success_story_image_job.rb` +- Delete: `app/services/success_story_image_generator.rb` + +- [ ] **Step 1: Create the concern** + +```ruby +# app/models/concerns/post/og_image_generatable.rb +module Post::OgImageGeneratable + extend ActiveSupport::Concern + + OG_TEMPLATE_PATH = Rails.root.join("app", "assets", "images", "success_story_template.webp") + OG_LOGO_MAX_WIDTH = 410 + OG_LOGO_MAX_HEIGHT = 190 + OG_LOGO_CENTER_X = 410 + OG_LOGO_CENTER_Y = 145 + + # Generate OG image by overlaying SVG logo on success story template + def generate_og_image!(force: false) + return unless success_story? && logo_svg.present? + return unless system("which", "convert", out: File::NULL, err: File::NULL) + + if force && featured_image.attached? + featured_image.purge + end + + return if !force && featured_image.attached? + + webp_data = composite_logo_on_template + return unless webp_data && !webp_data.empty? + + featured_image.attach( + io: StringIO.new(webp_data), + filename: "#{slug}-social.webp", + content_type: "image/webp" + ) + + process_image_variants! if featured_image.attached? + end + + private + + def composite_logo_on_template + svg_file = Tempfile.new([ "logo", ".svg" ]) + logo_file = Tempfile.new([ "logo_converted", ".webp" ]) + output_file = Tempfile.new([ "success_story", ".webp" ]) + + begin + svg_file.write(logo_svg) + svg_file.rewind + + return nil unless File.exist?(OG_TEMPLATE_PATH) + + converted = try_rsvg_convert(svg_file.path, logo_file.path) || + try_imagemagick_convert(svg_file.path, logo_file.path) + + return nil unless converted + + require "open3" + stdout, status = Open3.capture2("identify", "-format", "%wx%h", logo_file.path) + return nil unless status.success? + + logo_width, logo_height = stdout.strip.split("x").map(&:to_i) + x_offset = OG_LOGO_CENTER_X - (logo_width / 2) + y_offset = OG_LOGO_CENTER_Y - (logo_height / 2) + + composite_cmd = [ + "convert", OG_TEMPLATE_PATH.to_s, logo_file.path, + "-geometry", "+#{x_offset}+#{y_offset}", + "-composite", "-quality", "95", + "-define", "webp:method=4", + "webp:#{output_file.path}" + ] + + return nil unless system(*composite_cmd) + + File.read(output_file.path) + rescue => e + Rails.logger.error "Failed to generate success story image: #{e.message}" + nil + ensure + [ svg_file, logo_file, output_file ].each { |f| f.close; f.unlink } + end + end + + def try_rsvg_convert(svg_path, output_path) + return false unless system("which", "rsvg-convert", out: File::NULL, err: File::NULL) + + temp_high_res = Tempfile.new([ "high_res", ".png" ]) + + begin + rsvg_cmd = [ + "rsvg-convert", "--keep-aspect-ratio", + "--width", (OG_LOGO_MAX_WIDTH * 2).to_s, + "--height", (OG_LOGO_MAX_HEIGHT * 2).to_s, + "--background-color", "transparent", + svg_path, "--output", temp_high_res.path + ] + + return false unless system(*rsvg_cmd, err: File::NULL) + + resize_cmd = [ + "convert", temp_high_res.path, + "-resize", "#{OG_LOGO_MAX_WIDTH}x#{OG_LOGO_MAX_HEIGHT}>", + "-filter", "Lanczos", "-quality", "95", + "-background", "none", "-gravity", "center", + "-define", "webp:method=6", "-define", "webp:alpha-quality=100", + "webp:#{output_path}" + ] + + system(*resize_cmd) + ensure + temp_high_res.close + temp_high_res.unlink + end + end + + def try_imagemagick_convert(svg_path, output_path) + cmd = [ + "convert", "-background", "none", "-density", "300", + svg_path, + "-resize", "#{OG_LOGO_MAX_WIDTH}x#{OG_LOGO_MAX_HEIGHT}>", + "-filter", "Lanczos", "-quality", "95", + "-gravity", "center", + "-define", "webp:method=6", "-define", "webp:alpha-quality=100", + "webp:#{output_path}" + ] + + system(*cmd) + end +end +``` + +- [ ] **Step 2: Include concern in Post, update callback and job** + +In `app/models/post.rb`: +- Add `include Post::OgImageGeneratable` +- Remove the private `generate_success_story_image` method +- Change the `after_save` callback from: + +```ruby +after_save :generate_success_story_image, if: -> { success_story? && saved_change_to_logo_svg? } +``` + +to: + +```ruby +after_save :enqueue_og_image_generation, if: -> { success_story? && saved_change_to_logo_svg? } +``` + +Add private method: + +```ruby +def enqueue_og_image_generation + force = saved_change_to_logo_svg? && !saved_change_to_id? + GenerateSuccessStoryImageJob.perform_later(self, force: force) +end +``` + +Replace `app/jobs/generate_success_story_image_job.rb`: + +```ruby +class GenerateSuccessStoryImageJob < ApplicationJob + queue_as :default + + def perform(post, force: false) + post.generate_og_image!(force: force) + rescue => e + Rails.logger.error "Failed to generate success story image for post #{post.id}: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + end +end +``` + +- [ ] **Step 3: Delete old service** + +```bash +rm app/services/success_story_image_generator.rb +``` + +- [ ] **Step 4: Run tests** + +Run: `rails test` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor: move SuccessStoryImageGenerator to Post::OgImageGeneratable concern" +``` + +--- + +## Task 6: User::GithubSyncable — GithubDataFetcher → concern + +**Files:** +- Create: `app/models/concerns/user/github_syncable.rb` +- Modify: `app/models/user.rb` +- Modify: `app/jobs/update_github_data_job.rb` +- Delete: `app/services/github_data_fetcher.rb` + +- [ ] **Step 1: Create the concern** + +Move the full content of `GithubDataFetcher` into a concern. The class methods become `class_methods do` block, instance methods become regular concern methods: + +```ruby +# app/models/concerns/user/github_syncable.rb +require "net/http" +require "json" + +module User::GithubSyncable + extend ActiveSupport::Concern + + GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql" + + # Sync from OAuth callback data + def sync_github_data_from_oauth!(auth_data) + api_token = auth_data.credentials.token + + if auth_data.extra&.raw_info + raw_info = auth_data.extra.raw_info + update!( + username: auth_data.info.nickname, + email: auth_data.info.email, + name: raw_info.name || name, + bio: raw_info.bio || bio, + company: raw_info.company, + website: raw_info.blog.presence || website, + twitter: raw_info.twitter_username.presence || twitter, + location: raw_info.location, + avatar_url: auth_data.info.image + ) + end + + github_username = auth_data.info.nickname || username + return unless github_username.present? + + repos = fetch_ruby_repositories(github_username, api_token) + if repos.present? + repos.each { |r| r[:github_url] ||= r.delete(:url) } + self.class.sync_projects!(self, repos) + end + + update!(github_data_updated_at: Time.current) + rescue => e + Rails.logger.error "Failed to sync GitHub data for #{username}: #{e.message}" + end + + class_methods do + # Batch GraphQL fetch for multiple users + def batch_sync_github_data!(users, api_token: nil) + api_token ||= Rails.application.credentials.dig(:github, :api_token) + return { updated: 0, failed: users.size, errors: [ "No API token configured" ] } unless api_token.present? + + users_with_usernames = users.select { |u| u.username.present? } + return { updated: 0, failed: 0, errors: [] } if users_with_usernames.empty? + + query = build_batch_query(users_with_usernames) + response = github_graphql_request(query, api_token, retries: 2) + + if response[:errors].present? && response[:data].nil? + error_msg = response[:errors].first.to_s + if error_msg.match?(/50[234]/) && users_with_usernames.size > 1 + Rails.logger.warn "Batch of #{users_with_usernames.size} failed with #{error_msg}, splitting in half..." + mid = users_with_usernames.size / 2 + first_half = batch_sync_github_data!(users_with_usernames[0...mid], api_token: api_token) + sleep(1) + second_half = batch_sync_github_data!(users_with_usernames[mid..], api_token: api_token) + + return { + updated: first_half[:updated] + second_half[:updated], + failed: first_half[:failed] + second_half[:failed], + errors: first_half[:errors] + second_half[:errors] + } + end + + return { updated: 0, failed: users_with_usernames.size, errors: response[:errors] } + end + + updated = 0 + failed = 0 + errors = [] + + users_with_usernames.each_with_index do |user, index| + user_data = response.dig(:data, :"user_#{index}") + repos_data = response.dig(:data, :"repos_#{index}", :nodes) + + if user_data.nil? + failed += 1 + errors << "User #{user.username} not found on GitHub" + next + end + + begin + update_user_from_graphql(user, user_data, repos_data || []) + updated += 1 + rescue => e + failed += 1 + errors << "Failed to update #{user.username}: #{e.message}" + Rails.logger.error "GraphQL batch update error for #{user.username}: #{e.message}" + end + end + + { updated: updated, failed: failed, errors: errors } + end + + def sync_projects!(user, repos_data, force_snapshot: false) + current_urls = repos_data.map { |r| r[:github_url] || r[:url] } + user.projects.active.where.not(github_url: current_urls).update_all(archived: true) + + repos_data.each do |repo_data| + url = repo_data[:github_url] || repo_data[:url] + project = user.projects.find_or_initialize_by(github_url: url) + + project.assign_attributes( + name: repo_data[:name], + description: repo_data[:description], + stars: repo_data[:stars].to_i, + forks_count: repo_data[:forks_count].to_i, + size: repo_data[:size].to_i, + topics: repo_data[:topics] || [], + pushed_at: repo_data[:pushed_at].present? ? Time.parse(repo_data[:pushed_at].to_s) : nil, + archived: false + ) + + project.save! + project.record_snapshot!(force: force_snapshot) + end + + visible = user.projects.visible + gained = visible.sum { |p| p.stars_gained } + user.update!( + github_repos_count: visible.count, + github_stars_sum: visible.sum(:stars), + stars_gained: gained + ) + end + + private + + def github_graphql_request(query, api_token, retries: 3) + uri = URI(GITHUB_GRAPHQL_ENDPOINT) + + retries.times do |attempt| + begin + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/json" + request["Authorization"] = "Bearer #{api_token}" + request.body = { query: query }.to_json + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) } + + if response.code == "200" + return JSON.parse(response.body, symbolize_names: true) + elsif %w[502 503 504].include?(response.code) && attempt < retries - 1 + sleep(2 ** (attempt + 1)) + next + else + return { errors: [ "HTTP #{response.code}: #{response.message}" ] } + end + rescue Net::OpenTimeout, Net::ReadTimeout => e + if attempt < retries - 1 + sleep(2 ** (attempt + 1)) + next + else + return { errors: [ "Request timed out: #{e.message}" ] } + end + end + end + end + + def build_batch_query(users) + user_queries = users.each_with_index.map do |user, index| + <<~GRAPHQL + user_#{index}: user(login: "#{user.username}") { + login + email + name + bio + company + websiteUrl + twitterUsername + location + avatarUrl + } + repos_#{index}: search(query: "user:#{user.username} language:Ruby fork:false archived:false sort:updated", type: REPOSITORY, first: 100) { + nodes { + ... on Repository { + name + description + stargazerCount + url + forks { + totalCount + } + diskUsage + pushedAt + repositoryTopics(first: 10) { + nodes { + topic { + name + } + } + } + } + } + } + GRAPHQL + end.join("\n") + + "query { #{user_queries} }" + end + + def update_user_from_graphql(user, profile_data, repos_data) + user.update!( + username: profile_data[:login], + email: profile_data[:email] || user.email, + name: profile_data[:name] || user.name, + bio: profile_data[:bio] || user.bio, + company: profile_data[:company], + website: profile_data[:websiteUrl].presence || user.website, + twitter: profile_data[:twitterUsername].presence || user.twitter, + location: profile_data[:location], + avatar_url: profile_data[:avatarUrl], + github_data_updated_at: Time.current + ) + + repos = repos_data.map do |repo| + { + name: repo[:name], + description: repo[:description], + stars: repo[:stargazerCount], + github_url: repo[:url], + forks_count: repo.dig(:forks, :totalCount) || 0, + size: repo[:diskUsage] || 0, + topics: (repo.dig(:repositoryTopics, :nodes) || []).map { |t| t.dig(:topic, :name) }.compact, + pushed_at: repo[:pushedAt] + } + end + + sync_projects!(user, repos, force_snapshot: true) + end + end + + private + + def fetch_ruby_repositories(github_username, api_token) + uri = URI("https://api.github.com/users/#{github_username}/repos?per_page=100&sort=pushed") + request = Net::HTTP::Get.new(uri) + request["Accept"] = "application/vnd.github.v3+json" + request["Authorization"] = "Bearer #{api_token}" if api_token.present? + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) } + + if response.code == "200" + repos = JSON.parse(response.body) + + repos.select do |repo| + next if repo["fork"] + repo["language"] == "Ruby" || + repo["description"]&.downcase&.include?("ruby") || + repo["name"]&.downcase&.include?("ruby") || + repo["name"]&.downcase&.include?("rails") + end.map do |repo| + { + name: repo["name"], + description: repo["description"], + stars: repo["stargazers_count"], + url: repo["html_url"], + forks_count: repo["forks_count"], + size: repo["size"], + topics: repo["topics"] || [], + pushed_at: repo["pushed_at"] + } + end.sort_by { |r| -r[:stars] } + else + Rails.logger.error "GitHub API returned #{response.code} for #{github_username}: #{response.body}" + [] + end + end +end +``` + +- [ ] **Step 2: Include concern in User, update from_omniauth** + +In `app/models/user.rb`: +- Add `include User::GithubSyncable` +- Change `from_omniauth` from: + +```ruby +def self.from_omniauth(auth) + user = where(github_id: auth.uid).first_or_create do |user| + user.email = auth.info.email + user.username = auth.info.nickname + user.avatar_url = auth.info.image + end + GithubDataFetcher.new(user, auth).fetch_and_update! + user +end +``` + +to: + +```ruby +def self.from_omniauth(auth) + user = where(github_id: auth.uid).first_or_create do |user| + user.email = auth.info.email + user.username = auth.info.nickname + user.avatar_url = auth.info.image + end + user.sync_github_data_from_oauth!(auth) + user +end +``` + +- [ ] **Step 3: Thin out UpdateGithubDataJob** + +Replace `app/jobs/update_github_data_job.rb`: + +```ruby +class UpdateGithubDataJob < ApplicationJob + queue_as :default + + BATCH_SIZE = 5 + + def perform + Rails.logger.info "Starting GitHub data update using GraphQL batch fetching..." + + total_updated = 0 + total_failed = 0 + all_errors = [] + + User.where.not(username: [ nil, "" ]).find_in_batches(batch_size: BATCH_SIZE) do |batch| + results = User.batch_sync_github_data!(batch) + + total_updated += results[:updated] + total_failed += results[:failed] + all_errors.concat(results[:errors]) if results[:errors].present? + + sleep 0.5 + end + + Rails.logger.info "GitHub data update completed. Updated: #{total_updated}, Failed: #{total_failed}" + + if all_errors.any? + Rails.logger.warn "Errors encountered: #{all_errors.first(10).join(', ')}#{all_errors.size > 10 ? '...' : ''}" + end + end +end +``` + +- [ ] **Step 4: Delete old service** + +```bash +rm app/services/github_data_fetcher.rb +``` + +- [ ] **Step 5: Run tests** + +Run: `rails test` +Expected: All pass + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: move GithubDataFetcher to User::GithubSyncable concern" +``` + +--- + +## Task 7: Delete app/services/ directory + +- [ ] **Step 1: Verify directory is empty and delete** + +```bash +ls app/services/ +rmdir app/services/ +rm -rf test/services/ +``` + +- [ ] **Step 2: Run tests** + +Run: `rails test` +Expected: All pass + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "chore: remove empty app/services/ directory" +``` + +--- + +## Task 8: Add purpose column to chats + +**Files:** +- Create: `db/migrate/XXXXXX_add_purpose_to_chats.rb` +- Modify: `app/models/chat.rb` + +- [ ] **Step 1: Generate migration** + +```bash +rails generate migration AddPurposeToChats purpose:string +``` + +- [ ] **Step 2: Edit the migration** + +```ruby +class AddPurposeToChats < ActiveRecord::Migration[8.0] + def change + add_column :chats, :purpose, :string, default: "conversation" + add_index :chats, :purpose + end +end +``` + +- [ ] **Step 3: Run migration** + +```bash +rails db:migrate +``` + +- [ ] **Step 4: Add scopes to Chat model** + +In `app/models/chat.rb`, add: + +```ruby +scope :conversations, -> { where(purpose: "conversation") } +scope :system, -> { where.not(purpose: "conversation") } +``` + +- [ ] **Step 5: Run tests** + +Run: `rails test` +Expected: All pass + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat: add purpose column to chats for system AI tracking" +``` + +--- + +## Task 9: Post::AiSummarizable — GenerateSummaryJob → RubyLLM + +**Files:** +- Create: `app/models/concerns/post/ai_summarizable.rb` +- Modify: `app/models/post.rb` +- Modify: `app/jobs/generate_summary_job.rb` + +- [ ] **Step 1: Create the concern** + +```ruby +# app/models/concerns/post/ai_summarizable.rb +module Post::AiSummarizable + extend ActiveSupport::Concern + + def generate_summary!(force: false) + return if summary.present? && !force + + text_to_summarize = prepare_text_for_summary + return if text_to_summarize.blank? || text_to_summarize.length < 50 + + chat = user.chats.create!( + purpose: "summary", + model: Model.find_by(model_id: RubyLLM.configuration.default_model) + ) + + prompt = "Output ONLY a single teaser sentence. No preamble. Maximum 200 characters. Hook the reader with the most intriguing aspect.\n\nTeaser:\n\n#{text_to_summarize}" + + response = chat.ask(prompt) + raw_summary = response.content + + return unless raw_summary.present? + + cleaned = clean_ai_summary(raw_summary) + update!(summary: cleaned) + broadcast_summary_update + rescue => e + Rails.logger.error "Failed to generate summary for post #{id}: #{e.message}" + end + + private + + def prepare_text_for_summary + if link? + text = fetch_external_content + text = "Title: #{title}\nURL: #{url}" if text.blank? + else + text = ActionView::Base.full_sanitizer.sanitize(content) + end + + text.to_s.truncate(6000) + end + + def clean_ai_summary(raw) + cleaned = raw.gsub(/^(Here is a |Here's a |Here are |Teaser: |The teaser: |One-sentence teaser: )/i, "") + cleaned = cleaned.gsub(/^(This article |This page |This resource |Learn about |Discover |Explore )/i, "") + cleaned = cleaned.gsub(/^["'](.+)["']$/, '\1') + cleaned.strip + end + + def broadcast_summary_update + Turbo::StreamsChannel.broadcast_replace_to( + "post_#{id}", + target: "post_#{id}_summary", + partial: "posts/summary", + locals: { post: self } + ) + end +end +``` + +- [ ] **Step 2: Include concern in Post** + +In `app/models/post.rb`, add: + +```ruby +include Post::AiSummarizable +``` + +- [ ] **Step 3: Thin out the job** + +Replace `app/jobs/generate_summary_job.rb`: + +```ruby +class GenerateSummaryJob < ApplicationJob + queue_as :default + + def perform(post, force: false) + post.generate_summary!(force: force) + end +end +``` + +- [ ] **Step 4: Run tests** + +Run: `rails test` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor: move summary generation to Post::AiSummarizable, use RubyLLM" +``` + +--- + +## Task 10: Testimonial::AiGeneratable + rename job + +**Files:** +- Create: `app/models/concerns/testimonial/ai_generatable.rb` +- Modify: `app/models/testimonial.rb` +- Rename: `app/jobs/generate_testimonial_fields_job.rb` → `app/jobs/generate_testimonial_job.rb` +- Rename: `test/jobs/generate_testimonial_fields_job_test.rb` → `test/jobs/generate_testimonial_job_test.rb` + +- [ ] **Step 1: Create the concern** + +```ruby +# app/models/concerns/testimonial/ai_generatable.rb +module Testimonial::AiGeneratable + extend ActiveSupport::Concern + + MAX_HEADING_RETRIES = 5 + + def generate_ai_fields! + existing_headings = Testimonial.where.not(id: id).where.not(heading: nil).pluck(:heading) + + user_context = [ user.display_name, user.bio, user.company ].compact_blank.join(", ") + system_prompt = build_generation_prompt(existing_headings) + user_prompt = "User: #{user_context}\nQuote: #{quote}" + + if ai_feedback.present? && ai_attempts > 0 + user_prompt += "\n\nPrevious feedback to address: #{ai_feedback}" + end + + chat = user.chats.create!( + purpose: "testimonial_generation", + model: Model.find_by(model_id: RubyLLM.configuration.default_model) + ) + + parsed = ask_and_parse(chat, system_prompt, user_prompt) + + unless parsed + update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") + broadcast_testimonial_update + return + end + + retries = 0 + while heading_taken?(parsed["heading"]) && retries < MAX_HEADING_RETRIES + retries += 1 + existing_headings << parsed["heading"] + retry_prompt = build_generation_prompt(existing_headings) + parsed = ask_and_parse(chat, retry_prompt, user_prompt) + break unless parsed + end + + unless parsed + update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") + broadcast_testimonial_update + return + end + + update!( + heading: parsed["heading"], + subheading: parsed["subheading"], + body_text: parsed["body_text"] + ) + ValidateTestimonialJob.perform_later(self) + rescue JSON::ParserError => e + Rails.logger.error "Failed to parse AI response for testimonial #{id}: #{e.message}" + update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") + broadcast_testimonial_update + end + + private + + def ask_and_parse(chat, system_prompt, user_prompt) + response = chat.ask("#{system_prompt}\n\n#{user_prompt}") + JSON.parse(response.content) + rescue JSON::ParserError + nil + rescue => e + Rails.logger.error "AI error in testimonial generation for #{id}: #{e.message}" + nil + end + + def heading_taken?(heading) + Testimonial.where.not(id: id).exists?(heading: heading) + end + + def build_generation_prompt(existing_headings) + taken = if existing_headings.any? + "These headings are ALREADY TAKEN and must NOT be used (pick a synonym or related concept instead): #{existing_headings.join(', ')}." + else + "No headings are taken yet — pick any fitting word." + end + + <<~PROMPT + You generate structured testimonial content for a Ruby programming language advocacy site. + Given a user's quote about why they love Ruby, generate: + + 1. heading: A unique 1-3 word heading that captures the THEME or FEELING of the quote. + Be creative and specific. Go beyond generic words. Think of evocative nouns, metaphors, compound phrases, or poetic concepts. + The heading must make sense as an answer to "Why Ruby?" — e.g. "Why Ruby?" → "Flow State", "Clarity", "Pure Joy". + Good examples: "Spark", "Flow State", "Quiet Power", "Warm Glow", "First Love", "Playground", "Second Nature", "Deep Roots", "Readable Code", "Clean Slate", "Smooth Sailing", "Expressiveness", "Old Friend", "Sharp Tools", "Creative Freedom", "Solid Ground", "Calm Waters", "Poetic Logic", "Builder's Joy", "Sweet Spot", "Hidden Gem", "Fresh Start", "True North", "Clarity", "Belonging", "Empowerment", "Momentum", "Simplicity", "Trust", "Confidence" + #{taken} + 2. subheading: A short tagline under 10 words. + 3. body_text: 2-3 sentences that EXTEND and DEEPEN the user's idea. Add new angles, examples, or implications. + Do NOT repeat or paraphrase what the user already said. Build on top of it. + + WRITING STYLE — sound like a real person, not an AI: + - NEVER use: delve, tapestry, landscape, foster, showcase, underscore, pivotal, vibrant, crucial, testament, additionally, interplay, intricate, enduring, garner, enhance + - NEVER use inflated phrases: "serves as", "stands as", "is a testament to", "highlights the importance of", "reflects broader", "setting the stage" + - NEVER use "It's not just X, it's Y" or "Not only X but also Y" parallelisms + - NEVER use rule-of-three lists (e.g., "elegant, expressive, and powerful") + - NEVER end with vague positivity ("the future looks bright", "exciting times ahead") + - AVOID -ing tack-ons: "ensuring...", "highlighting...", "fostering..." + - AVOID em dashes. Use commas or periods instead. + - AVOID filler: "In order to", "It is important to note", "Due to the fact that" + - USE simple verbs: "is", "has", "does" — not "serves as", "boasts", "features" + - BE specific and concrete. Say what Ruby actually does, not how significant it is. + - Write like a developer talking to a friend, not a press release. + + Respond with valid JSON only: {"heading": "...", "subheading": "...", "body_text": "..."} + PROMPT + end + +end +``` + +Note: The `broadcast_testimonial_update` method shared by both AI concerns is extracted to the Testimonial model itself — see Task 11 Step 2. + +- [ ] **Step 2: Include concern in Testimonial, update job reference** + +In `app/models/testimonial.rb`, add: + +```ruby +include Testimonial::AiGeneratable +``` + +Change the `process_quote_change` method from: + +```ruby +GenerateTestimonialFieldsJob.perform_later(self) +``` + +to: + +```ruby +GenerateTestimonialJob.perform_later(self) +``` + +- [ ] **Step 3: Rename and thin out the job** + +```bash +git mv app/jobs/generate_testimonial_fields_job.rb app/jobs/generate_testimonial_job.rb +git mv test/jobs/generate_testimonial_fields_job_test.rb test/jobs/generate_testimonial_job_test.rb +``` + +Replace `app/jobs/generate_testimonial_job.rb`: + +```ruby +class GenerateTestimonialJob < ApplicationJob + queue_as :default + + def perform(testimonial) + testimonial.generate_ai_fields! + end +end +``` + +Replace `test/jobs/generate_testimonial_job_test.rb`: + +```ruby +require "test_helper" + +class GenerateTestimonialJobTest < ActiveJob::TestCase + test "enqueues without error" do + testimonial = testimonials(:unpublished) + + assert_nothing_raised do + GenerateTestimonialJob.perform_later(testimonial) + end + end +end +``` + +- [ ] **Step 4: Run tests** + +Run: `rails test` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor: move testimonial generation to Testimonial::AiGeneratable, rename job" +``` + +--- + +## Task 11: Testimonial::AiValidatable — ValidateTestimonialJob → RubyLLM + +**Files:** +- Create: `app/models/concerns/testimonial/ai_validatable.rb` +- Modify: `app/models/testimonial.rb` +- Modify: `app/jobs/validate_testimonial_job.rb` + +- [ ] **Step 1: Create the concern** + +```ruby +# app/models/concerns/testimonial/ai_validatable.rb +module Testimonial::AiValidatable + extend ActiveSupport::Concern + + MAX_VALIDATION_ATTEMPTS = 3 + + def validate_with_ai! + existing = Testimonial.published.where.not(id: id) + .pluck(:heading, :quote) + .map { |h, q| "Heading: #{h}, Quote: #{q}" } + .join("\n") + + system_prompt = build_validation_prompt(existing) + + user_prompt = <<~PROMPT + Quote: #{quote} + Generated heading: #{heading} + Generated subheading: #{subheading} + Generated body: #{body_text} + PROMPT + + chat = user.chats.create!( + purpose: "testimonial_validation", + model: Model.find_by(model_id: RubyLLM.configuration.default_model) + ) + + response = chat.ask("#{system_prompt}\n\n#{user_prompt}") + parsed = JSON.parse(response.content) + + if parsed["publish"] + update!(published: true, ai_feedback: parsed["feedback"], reject_reason: nil) + elsif parsed["reject_reason"] == "quote" + update!(published: false, ai_feedback: parsed["feedback"], reject_reason: "quote") + elsif ai_attempts < MAX_VALIDATION_ATTEMPTS + update!( + ai_attempts: ai_attempts + 1, + ai_feedback: parsed["feedback"], + reject_reason: "generation", + published: false + ) + GenerateTestimonialJob.perform_later(self) + else + update!(published: false, ai_feedback: parsed["feedback"], reject_reason: "generation") + end + + broadcast_testimonial_update + rescue JSON::ParserError => e + Rails.logger.error "Failed to parse validation response for testimonial #{id}: #{e.message}" + update!(ai_feedback: "We couldn't validate your testimonial right now. Please try again later.") + broadcast_testimonial_update + rescue => e + Rails.logger.error "Failed to validate testimonial #{id}: #{e.message}" + update!(ai_feedback: "We couldn't validate your testimonial right now. Please try again later.") + broadcast_testimonial_update + end + + private + + def build_validation_prompt(existing_testimonials) + <<~PROMPT + You validate testimonials for a Ruby programming language advocacy site. + + CONTENT POLICY: + - Hate speech, slurs, personal attacks, or targeted insults toward individuals or groups are NEVER allowed. + - Casual expletives used positively (e.g., "Damn, Ruby is amazing!" or "Fuck, I love this language!") are ALLOWED. + - The key distinction: profanity expressing enthusiasm = OK. Profanity attacking or demeaning people/groups = NOT OK. + - The quote MUST express genuine love or appreciation for Ruby. This is an advocacy site — negative, dismissive, sarcastic, or trolling sentiments about Ruby are NOT allowed. + + VALIDATION RULES: + 1. First check the user's QUOTE against the content policy. If it violates (including being negative about Ruby), reject immediately with reject_reason "quote". + 2. If the quote is fine, check the AI-generated fields (heading/subheading/body). ONLY reject generation if there is a CLEAR problem: + - The body contradicts or misrepresents the quote + - The subheading is nonsensical or unrelated + - The content is factually wrong about Ruby + Do NOT reject for duplicate headings (handled elsewhere). Do NOT reject just because the fields could be "better" or "more creative". Good enough is good enough — publish it. + 3. If everything looks acceptable, publish it. + + AI-SOUNDING LANGUAGE CHECK: + Reject with reason "generation" if the generated heading/subheading/body contains: + - Words: delve, tapestry, landscape, foster, showcase, underscore, pivotal, vibrant, crucial, testament, additionally, interplay, intricate, enduring, garner, enhance + - Patterns: "serves as", "stands as", "is a testament to", "not just X, it's Y", "not only X but also Y" + - Rule-of-three adjective/noun lists + - Vague positive endings ("the future looks bright", "exciting times ahead") + - Superficial -ing tack-ons ("ensuring...", "highlighting...", "fostering...") + If the quote itself is fine but the generated text sounds like AI wrote it, set reject_reason to "generation" and explain which phrases sound artificial. + + Existing published testimonials (for context): + #{existing_testimonials.presence || "None yet."} + + Respond with valid JSON only: {"publish": true/false, "reject_reason": "quote" or "generation" or null, "feedback": "..."} + - reject_reason "quote": the user's quote violates content policy or is not meaningful. Feedback should tell the USER what to fix. + - reject_reason "generation": quote is fine but generated fields have a specific problem. Feedback must be a SPECIFIC INSTRUCTION for the AI generator, e.g., "The heading 'X' is already taken, use a different word" or "The body contradicts the quote by saying Y when the user said Z". Be concrete. + - reject_reason null: publishing. Feedback should be a short positive note for the user. + PROMPT + end +end +``` + +- [ ] **Step 2: Include concern in Testimonial, add shared broadcast method** + +In `app/models/testimonial.rb`, add: + +```ruby +include Testimonial::AiValidatable +``` + +Add a shared private method to the Testimonial model itself (used by both AI concerns): + +```ruby +private + +def broadcast_testimonial_update + Turbo::StreamsChannel.broadcast_replace_to( + "testimonial_#{id}", + target: "testimonial_section", + partial: "testimonials/section", + locals: { testimonial: self, user: user } + ) +end +``` + +- [ ] **Step 3: Thin out the job** + +Replace `app/jobs/validate_testimonial_job.rb`: + +```ruby +class ValidateTestimonialJob < ApplicationJob + queue_as :default + + def perform(testimonial) + testimonial.validate_with_ai! + end +end +``` + +- [ ] **Step 4: Update test** + +Replace `test/jobs/validate_testimonial_job_test.rb`: + +```ruby +require "test_helper" + +class ValidateTestimonialJobTest < ActiveJob::TestCase + test "enqueues without error" do + testimonial = testimonials(:unpublished) + + assert_nothing_raised do + ValidateTestimonialJob.perform_later(testimonial) + end + end +end +``` + +- [ ] **Step 5: Run tests** + +Run: `rails test` +Expected: All pass + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: move testimonial validation to Testimonial::AiValidatable, use RubyLLM" +``` + +--- + +## Task 12: Remove ruby-openai and anthropic gems + +**Files:** +- Modify: `Gemfile` + +- [ ] **Step 1: Remove gems from Gemfile** + +Remove these lines: + +```ruby +gem "ruby-openai", "~> 8.2" +gem "anthropic", "~> 1.6.0" +``` + +- [ ] **Step 2: Bundle** + +```bash +bundle install +``` + +- [ ] **Step 3: Run tests** + +Run: `rails test` +Expected: All pass (no code references these gems anymore) + +- [ ] **Step 4: Commit** + +```bash +git add Gemfile Gemfile.lock +git commit -m "chore: remove ruby-openai and anthropic gems, RubyLLM handles all AI" +``` + +--- + +## Task 13: Update AGENTS.md + +**Files:** +- Modify: `AGENTS.md` + +- [ ] **Step 1: Rewrite AGENTS.md** + +Rewrite to accurately describe WhyRuby's architecture post-refactor. Key sections: +- Project Overview (WhyRuby.info / RubyCommunity.org, Ruby 4.0.1 / Rails 8.2, Solid Stack) +- Authentication (GitHub OAuth, session-based, no Devise) +- Architecture (37signals vanilla Rails, fat models with concerns, thin controllers, no service objects) +- Data Model (Universal Content Model, key models, UUIDv7) +- AI Operations (RubyLLM, system chats with purpose for cost tracking, per-user spending) +- Concern Catalog (list all model concerns and what they do) +- Multi-Domain Setup (whyruby.info + rubycommunity.org) +- MCP (keep existing docs, it's functional) +- Development Commands, Testing, Deployment, Credentials + +Remove template-specific sections about magic link auth, team billing/pricing details not relevant to WhyRuby. + +- [ ] **Step 2: Run CI to verify nothing broke** + +```bash +rails test && bundle exec rubocop && bin/brakeman --no-pager +``` + +- [ ] **Step 3: Commit** + +```bash +git add AGENTS.md +git commit -m "docs: rewrite AGENTS.md to reflect WhyRuby's actual architecture" +``` + +--- + +## Task 14: Final verification + +- [ ] **Step 1: Run full CI pipeline** + +```bash +bin/ci +``` + +Expected: All checks pass (RuboCop, tests, Brakeman, i18n) + +- [ ] **Step 2: Verify no service references remain** + +```bash +grep -r "app/services\|LocationNormalizer\|TimezoneResolver\|SvgSanitizer\|MetadataFetcher\|ImageProcessor\|SuccessStoryImageGenerator\|GithubDataFetcher\|GenerateTestimonialFieldsJob" app/ test/ --include="*.rb" +``` + +Expected: No matches + +- [ ] **Step 3: Verify app/services/ directory is gone** + +```bash +ls app/services/ 2>&1 +``` + +Expected: "No such file or directory" diff --git a/docs/superpowers/specs/2026-04-06-37signals-refactor-design.md b/docs/superpowers/specs/2026-04-06-37signals-refactor-design.md new file mode 100644 index 0000000..1d7248f --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-37signals-refactor-design.md @@ -0,0 +1,316 @@ +# 37signals Style Refactor + RubyLLM Migration + +## Goal + +Refactor WhyRuby to follow 37signals vanilla Rails patterns (as codified in `.claude/rules/`) and switch all AI operations from raw OpenAI/Anthropic gems to RubyLLM with per-user cost tracking. Update AGENTS.md to accurately describe the project. + +## Scope + +1. Eliminate all service objects — move logic into model concerns +2. Migrate AI jobs from `ruby-openai`/`anthropic` gems to `ruby_llm` +3. Track per-user AI spending via existing Chat/Message infrastructure +4. Rename `GenerateTestimonialFieldsJob` to `GenerateTestimonialJob` +5. Remove `ruby-openai` and `anthropic` gems from Gemfile +6. Update AGENTS.md to reflect WhyRuby's actual architecture + +--- + +## Part 1: Eliminate Service Objects + +Move all 7 services from `app/services/` into model concerns. Delete `app/services/` directory when done. + +### 1.1 `GithubDataFetcher` → `User::GithubSyncable` + +**File**: `app/models/concerns/user/github_syncable.rb` + +Moves all GitHub GraphQL/REST fetching logic into a User concern. The User model becomes the coordinator. + +**Public interface on User**: +- `sync_github_data!` — full sync (called from `UpdateGithubDataJob`) +- `sync_github_data_from_oauth!(auth_data)` — lightweight sync from OAuth callback +- `self.batch_sync_github_data!(users)` — batch GraphQL fetch for multiple users + +**Private helpers stay as private methods in the concern**: +- `graphql_request`, `build_batch_query`, `fetch_ruby_repositories` +- `update_from_graphql`, `sync_projects!` + +**Job becomes thin**: +```ruby +class UpdateGithubDataJob < ApplicationJob + def perform(users) + User.batch_sync_github_data!(users) + end +end +``` + +### 1.2 `ImageProcessor` → `Post::ImageVariantable` + +**File**: `app/models/concerns/post/image_variantable.rb` + +Moves image variant generation (tile/post/og WebP variants via ImageMagick) into a Post concern. + +**Public interface on Post**: +- `process_image_variants!` — generates all variants from featured_image +- `image_variant(size)` — returns variant blob (already exists on Post) + +**Existing Post callbacks** (`process_featured_image_if_needed`) call `process_image_variants!` directly instead of `ImageProcessor.new(self).process!`. + +### 1.3 `SuccessStoryImageGenerator` → `Post::OgImageGeneratable` + +**File**: `app/models/concerns/post/og_image_generatable.rb` + +Moves success story OG image generation (SVG-to-WebP overlay) into a Post concern. + +**Public interface on Post**: +- `generate_og_image!` — converts logo_svg to WebP, composites onto template, attaches as featured_image, then calls `process_image_variants!` + +**Job becomes thin**: +```ruby +class GenerateSuccessStoryImageJob < ApplicationJob + def perform(post) + post.generate_og_image! + end +end +``` + +### 1.4 `SvgSanitizer` → `Post::SvgSanitizable` + +**File**: `app/models/concerns/post/svg_sanitizable.rb` + +Moves SVG sanitization (whitelist elements/attributes, fix viewBox, remove scripts) into a Post concern. + +**Public interface on Post**: +- `sanitize_logo_svg!` — sanitizes `logo_svg` in place + +Called from existing `clean_logo_svg` before_validation callback. Internal helpers (`fix_svg_case_sensitivity`, `fix_viewbox_offset`, etc.) become private methods. + +### 1.5 `LocationNormalizer` + `TimezoneResolver` → `User::Geocodable` + +**File**: `app/models/concerns/user/geocodable.rb` + +Merges location normalization (Photon API) and timezone resolution into one concern. + +**Public interface on User**: +- `geocode!` — normalizes location string, sets lat/lng/normalized_location/timezone + +**Job becomes thin**: +```ruby +class NormalizeLocationJob < ApplicationJob + def perform(user) + user.geocode! + end +end +``` + +### 1.6 `MetadataFetcher` → `Post::MetadataFetchable` + +**File**: `app/models/concerns/post/metadata_fetchable.rb` + +Moves OpenGraph metadata fetching (title, description, image from URLs) into a Post concern. + +**Public interface on Post**: +- `fetch_metadata!` — fetches OG metadata from `url`, returns hash +- `fetch_external_content` — fetches page text content (used by AI summarization) + +Called from `PostsController#fetch_metadata` action and `GenerateSummaryJob`. + +--- + +## Part 2: Migrate AI to RubyLLM + Per-User Cost Tracking + +### 2.1 Strategy: System Chats for Background AI + +Each AI background operation creates a Chat/Message pair attributed to the triggering user. This reuses the existing `acts_as_chat` / `acts_as_message` infrastructure for automatic token counting and cost tracking. + +**Pattern**: +```ruby +# In a concern or job +chat = user.chats.create!(model: Model.find_by(model_id: "gpt-4.1-nano")) +chat.ask("prompt here") +# RubyLLM automatically populates tokens on the Message +# Message#calculate_cost fires, updating chat/user total_cost +``` + +We add a `purpose` column to `chats` to distinguish system-generated chats from user-initiated ones: + +```ruby +# Migration +add_column :chats, :purpose, :string, default: "conversation" +add_index :chats, :purpose +``` + +Purposes: `"conversation"` (user chat), `"summary"`, `"testimonial_generation"`, `"testimonial_validation"`, `"translation"`. + +System chats are hidden from the user's chat list via scope: +```ruby +scope :conversations, -> { where(purpose: "conversation") } +scope :system, -> { where.not(purpose: "conversation") } +``` + +### 2.2 `Post::AiSummarizable` concern + +**File**: `app/models/concerns/post/ai_summarizable.rb` + +Replaces `GenerateSummaryJob`'s raw Anthropic/OpenAI calls with RubyLLM. + +**Public interface on Post**: +- `generate_summary!(force: false)` — creates system chat, asks for summary, updates post + +**Job becomes thin**: +```ruby +class GenerateSummaryJob < ApplicationJob + def perform(post, force: false) + post.generate_summary!(force: force) + end +end +``` + +**Implementation**: +- Creates a chat with `purpose: "summary"` for `post.user` +- For link posts, calls `fetch_external_content` (from `MetadataFetchable`) to get page text +- Sends single prompt via `chat.ask(prompt)` +- Cleans summary text (remove meta-language), updates `post.summary` +- Broadcasts update via Turbo Streams + +### 2.3 `Testimonial::AiGeneratable` concern + +**File**: `app/models/concerns/testimonial/ai_generatable.rb` + +Replaces `GenerateTestimonialFieldsJob`'s raw API calls. + +**Public interface on Testimonial**: +- `generate_ai_fields!` — creates system chat, generates heading/subheading/body_text + +**Job renamed to `GenerateTestimonialJob`** (from `GenerateTestimonialFieldsJob`): +```ruby +class GenerateTestimonialJob < ApplicationJob + def perform(testimonial) + testimonial.generate_ai_fields! + end +end +``` + +**Implementation**: +- Creates chat with `purpose: "testimonial_generation"` for `testimonial.user` +- Includes existing heading collision retry logic (up to 5 retries) +- On success, enqueues `ValidateTestimonialJob` + +### 2.4 `Testimonial::AiValidatable` concern + +**File**: `app/models/concerns/testimonial/ai_validatable.rb` + +Replaces `ValidateTestimonialJob`'s raw API calls. + +**Public interface on Testimonial**: +- `validate_with_ai!` — creates system chat, validates content, publishes or rejects + +**Job becomes thin**: +```ruby +class ValidateTestimonialJob < ApplicationJob + def perform(testimonial) + testimonial.validate_with_ai! + end +end +``` + +**Implementation**: +- Creates chat with `purpose: "testimonial_validation"` for `testimonial.user` +- Parses JSON response for publish/reject decision +- On rejection with reason "generation" and attempts < 3, re-enqueues `GenerateTestimonialJob` +- Broadcasts Turbo Stream update + +### 2.5 Update `TranslateContentJob` + +Already uses RubyLLM. Change: attribute the chat to a user with `purpose: "translation"` instead of creating a bare `RubyLLM.chat()`. The user is determined from the translatable record: if the record responds to `user`, use that; otherwise use `Current.user` or a system-level admin user as fallback (translations are team-initiated, not always user-specific). + +### 2.6 Gem Cleanup + +Remove from Gemfile: +- `gem "ruby-openai"` +- `gem "anthropic"` + +Keep: +- `gem "ruby_llm"` (already present) + +RubyLLM handles both OpenAI and Anthropic providers through its unified interface. + +### 2.7 RubyLLM Model Selection + +All background AI jobs use a small, cheap model. Current jobs use `claude-3-haiku` / `gpt-3.5-turbo`. With RubyLLM, standardize on `gpt-4.1-nano` (already configured as `default_model` in `config/initializers/ruby_llm.rb`). Each concern can override the model if needed. + +--- + +## Part 3: Update AGENTS.md + +Rewrite AGENTS.md to accurately describe WhyRuby instead of the template's generic SaaS. Key changes: + +- **Project Overview**: WhyRuby.info / RubyCommunity.org content advocacy site +- **Authentication**: GitHub OAuth (not magic links) +- **Architecture section**: 37signals vanilla Rails, concerns over services, no service objects +- **AI section**: RubyLLM for all AI, system chats for cost tracking +- **Remove template-specific sections**: Team billing/pricing, magic link auth descriptions +- **Keep MCP section**: MCP tools/resources are functional in the codebase — document them accurately +- **Keep relevant sections**: Multi-domain setup, community features, Solid Stack, deployment +- **Add concern catalog**: List all model concerns and what they do + +--- + +## Part 4: File Changes Summary + +### New Files +- `app/models/concerns/user/github_syncable.rb` +- `app/models/concerns/user/geocodable.rb` +- `app/models/concerns/post/image_variantable.rb` +- `app/models/concerns/post/og_image_generatable.rb` +- `app/models/concerns/post/svg_sanitizable.rb` +- `app/models/concerns/post/metadata_fetchable.rb` +- `app/models/concerns/post/ai_summarizable.rb` +- `app/models/concerns/testimonial/ai_generatable.rb` +- `app/models/concerns/testimonial/ai_validatable.rb` +- `db/migrate/XXXXXX_add_purpose_to_chats.rb` + +### Modified Files +- `app/models/user.rb` — include new concerns +- `app/models/post.rb` — include new concerns, remove service calls +- `app/models/testimonial.rb` — include new concerns +- `app/models/chat.rb` — add purpose scopes +- `app/jobs/generate_summary_job.rb` — thin out to delegate to concern +- `app/jobs/generate_testimonial_job.rb` — renamed, thin out +- `app/jobs/validate_testimonial_job.rb` — thin out +- `app/jobs/generate_success_story_image_job.rb` — thin out +- `app/jobs/normalize_location_job.rb` — thin out +- `app/jobs/update_github_data_job.rb` — thin out +- `app/jobs/translate_content_job.rb` — use system chat for cost tracking +- `app/controllers/posts_controller.rb` — use Post methods instead of service calls +- `app/controllers/users/omniauth_callbacks_controller.rb` — use User methods +- `Gemfile` — remove ruby-openai, anthropic +- `AGENTS.md` — full rewrite + +### Deleted Files +- `app/services/github_data_fetcher.rb` +- `app/services/image_processor.rb` +- `app/services/success_story_image_generator.rb` +- `app/services/svg_sanitizer.rb` +- `app/services/location_normalizer.rb` +- `app/services/timezone_resolver.rb` +- `app/services/metadata_fetcher.rb` +- `app/services/` directory itself + +### Renamed Files +- `app/jobs/generate_testimonial_fields_job.rb` → `app/jobs/generate_testimonial_job.rb` + +--- + +## Testing Strategy + +- Existing tests should continue to pass after each concern migration (same behavior, different location) +- Update test references from service class names to model method calls +- For RubyLLM migration: stub `Chat#ask` in tests instead of stubbing raw API clients +- Add tests for `purpose` scoping on Chat +- Run `rails test` after each Part to verify nothing breaks + +## Migration Order + +1. **Part 1 first** (service → concern migrations) — pure refactor, no behavior change +2. **Part 2 second** (AI migration) — changes AI provider interface +3. **Part 3 last** (AGENTS.md) — documentation reflects final state diff --git a/lib/generators/mcp/crud/crud_generator.rb b/lib/generators/mcp/crud/crud_generator.rb new file mode 100644 index 0000000..59f10f9 --- /dev/null +++ b/lib/generators/mcp/crud/crud_generator.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Mcp + module Generators + class CrudGenerator < Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + class_option :skip_resource, type: :boolean, default: false, desc: "Skip resource generation" + + ACTIONS = %w[list show create update delete].freeze + + def create_tool_files + ACTIONS.each do |action| + @action = action + template "#{action}_tool.rb.tt", File.join("app/tools", plural_name, "#{action}_#{singular_name}_tool.rb") + template "#{action}_tool_test.rb.tt", File.join("test/tools", plural_name, "#{action}_#{singular_name}_tool_test.rb") + end + end + + def create_resource_file + return if options[:skip_resource] + + template "resource.rb.tt", File.join("app/resources/mcp", "#{plural_name}_resource.rb") + template "resource_test.rb.tt", File.join("test/resources", "#{plural_name}_resource_test.rb") + end + + private + + def model_class + class_name.singularize + end + + def plural_class_name + class_name.pluralize.camelize + end + + def humanized_name + singular_name.humanize + end + + def humanized_plural + plural_name.humanize + end + end + end +end diff --git a/lib/generators/mcp/crud/templates/create_tool.rb.tt b/lib/generators/mcp/crud/templates/create_tool.rb.tt new file mode 100644 index 0000000..a0722e7 --- /dev/null +++ b/lib/generators/mcp/crud/templates/create_tool.rb.tt @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module <%= plural_class_name %> + class Create<%= model_class %>Tool < ApplicationTool + description "Create a new <%= humanized_name %>" + + annotations( + title: "Create <%= humanized_name %>", + read_only_hint: false, + open_world_hint: false + ) + + arguments do + # Add required arguments for creation + # required(:name).filled(:string).description("The <%= singular_name %> name") + end + + def call(**args) + require_authentication! + + <%= singular_name %> = nil + with_current_user do + <%= singular_name %> = current_user.<%= plural_name %>.create!(args) + end + + success_response( + serialize_<%= singular_name %>(<%= singular_name %>), + message: "<%= humanized_name %> created successfully" + ) + rescue ActiveRecord::RecordInvalid => e + error_response(e.message, code: "validation_error") + end + + private + + def serialize_<%= singular_name %>(item) + { + id: item.id, + created_at: format_timestamp(item.created_at) + } + end + end +end diff --git a/lib/generators/mcp/crud/templates/create_tool_test.rb.tt b/lib/generators/mcp/crud/templates/create_tool_test.rb.tt new file mode 100644 index 0000000..dc15281 --- /dev/null +++ b/lib/generators/mcp/crud/templates/create_tool_test.rb.tt @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module <%= plural_class_name %> + class Create<%= model_class %>ToolTest < McpToolTestCase + setup do + @user = users(:one) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(<%= plural_class_name %>::Create<%= model_class %>Tool) + end + assert_match(/Authentication required/, error.message) + end + + # TODO: Add test for successful creation with valid attributes + end +end diff --git a/lib/generators/mcp/crud/templates/delete_tool.rb.tt b/lib/generators/mcp/crud/templates/delete_tool.rb.tt new file mode 100644 index 0000000..99eb93e --- /dev/null +++ b/lib/generators/mcp/crud/templates/delete_tool.rb.tt @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module <%= plural_class_name %> + class Delete<%= model_class %>Tool < ApplicationTool + description "Delete a <%= humanized_name %>" + + annotations( + title: "Delete <%= humanized_name %>", + read_only_hint: false, + destructive_hint: true, + open_world_hint: false + ) + + arguments do + required(:id).filled(:string).description("The <%= singular_name %> ID to delete") + end + + def call(id:) + require_authentication! + + <%= singular_name %> = current_user.<%= plural_name %>.find_by(id: id) + return error_response("<%= humanized_name %> not found", code: "not_found") unless <%= singular_name %> + + with_current_user do + <%= singular_name %>.destroy! + end + + success_response( + { id: id }, + message: "<%= humanized_name %> deleted successfully" + ) + end + end +end diff --git a/lib/generators/mcp/crud/templates/delete_tool_test.rb.tt b/lib/generators/mcp/crud/templates/delete_tool_test.rb.tt new file mode 100644 index 0000000..5536dbd --- /dev/null +++ b/lib/generators/mcp/crud/templates/delete_tool_test.rb.tt @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module <%= plural_class_name %> + class Delete<%= model_class %>ToolTest < McpToolTestCase + setup do + @user = users(:one) + # @<%= singular_name %> = <%= plural_name %>(:one) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(<%= plural_class_name %>::Delete<%= model_class %>Tool, id: "test") + end + assert_match(/Authentication required/, error.message) + end + + test "returns error for non-existent <%= singular_name %>" do + mock_mcp_request(user: @user) + + result = call_tool(<%= plural_class_name %>::Delete<%= model_class %>Tool, id: "nonexistent") + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + end +end diff --git a/lib/generators/mcp/crud/templates/list_tool.rb.tt b/lib/generators/mcp/crud/templates/list_tool.rb.tt new file mode 100644 index 0000000..6655a35 --- /dev/null +++ b/lib/generators/mcp/crud/templates/list_tool.rb.tt @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module <%= plural_class_name %> + class List<%= plural_class_name %>Tool < ApplicationTool + description "List <%= humanized_plural %>" + + annotations( + title: "List <%= humanized_plural %>", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + optional(:limit).filled(:integer).description("Maximum number of <%= plural_name %> to return (default: 20)") + optional(:offset).filled(:integer).description("Number of <%= plural_name %> to skip (default: 0)") + end + + def call(limit: 20, offset: 0) + require_authentication! + + <%= plural_name %> = current_user.<%= plural_name %>.offset(offset).limit(limit) + + success_response( + <%= plural_name %>.map { |item| serialize_<%= singular_name %>(item) }, + message: "Found #{<%= plural_name %>.size} <%= plural_name %>" + ) + end + + private + + def serialize_<%= singular_name %>(item) + { + id: item.id, + created_at: format_timestamp(item.created_at), + updated_at: format_timestamp(item.updated_at) + } + end + end +end diff --git a/lib/generators/mcp/crud/templates/list_tool_test.rb.tt b/lib/generators/mcp/crud/templates/list_tool_test.rb.tt new file mode 100644 index 0000000..d601132 --- /dev/null +++ b/lib/generators/mcp/crud/templates/list_tool_test.rb.tt @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module <%= plural_class_name %> + class List<%= plural_class_name %>ToolTest < McpToolTestCase + setup do + @user = users(:one) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(<%= plural_class_name %>::List<%= plural_class_name %>Tool) + end + assert_match(/Authentication required/, error.message) + end + + test "returns <%= plural_name %> when authenticated" do + mock_mcp_request(user: @user) + + result = call_tool(<%= plural_class_name %>::List<%= plural_class_name %>Tool) + + assert result[:success] + assert_kind_of Array, result[:data] + end + end +end diff --git a/lib/generators/mcp/crud/templates/resource.rb.tt b/lib/generators/mcp/crud/templates/resource.rb.tt new file mode 100644 index 0000000..db2bb62 --- /dev/null +++ b/lib/generators/mcp/crud/templates/resource.rb.tt @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mcp + class <%= plural_class_name %>Resource < ApplicationResource + uri "app:///<%= plural_name %>" + resource_name "User <%= humanized_plural %>" + description "List of user's <%= plural_name %>" + mime_type "application/json" + + def content + return to_json({ error: "Not authenticated" }) unless authenticated? + + <%= plural_name %> = current_user.<%= plural_name %> + + to_json({ + <%= plural_name %>_count: <%= plural_name %>.count, + <%= plural_name %>: <%= plural_name %>.map { |item| serialize_<%= singular_name %>(item) } + }) + end + + private + + def serialize_<%= singular_name %>(item) + { + id: item.id, + created_at: format_timestamp(item.created_at), + updated_at: format_timestamp(item.updated_at) + } + end + end +end diff --git a/lib/generators/mcp/crud/templates/resource_test.rb.tt b/lib/generators/mcp/crud/templates/resource_test.rb.tt new file mode 100644 index 0000000..680464c --- /dev/null +++ b/lib/generators/mcp/crud/templates/resource_test.rb.tt @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +class <%= plural_class_name %>ResourceTest < McpResourceTestCase + setup do + @user = users(:one) + end + + test "returns error when not authenticated" do + result = parse_resource(call_resource(Mcp::<%= plural_class_name %>Resource)) + + assert result[:error].present? + assert_match(/Not authenticated/, result[:error]) + end + + test "returns <%= plural_name %> when authenticated" do + mock_mcp_request(user: @user) + + result = parse_resource(call_resource(Mcp::<%= plural_class_name %>Resource)) + + assert result[:<%= plural_name %>_count].present? + assert_kind_of Array, result[:<%= plural_name %>] + end +end diff --git a/lib/generators/mcp/crud/templates/show_tool.rb.tt b/lib/generators/mcp/crud/templates/show_tool.rb.tt new file mode 100644 index 0000000..6bd365b --- /dev/null +++ b/lib/generators/mcp/crud/templates/show_tool.rb.tt @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module <%= plural_class_name %> + class Show<%= model_class %>Tool < ApplicationTool + description "Get details of a specific <%= humanized_name %>" + + annotations( + title: "Show <%= humanized_name %>", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + required(:id).filled(:string).description("The <%= singular_name %> ID") + end + + def call(id:) + require_authentication! + + <%= singular_name %> = current_user.<%= plural_name %>.find_by(id: id) + return error_response("<%= humanized_name %> not found", code: "not_found") unless <%= singular_name %> + + success_response(serialize_<%= singular_name %>(<%= singular_name %>)) + end + + private + + def serialize_<%= singular_name %>(item) + { + id: item.id, + created_at: format_timestamp(item.created_at), + updated_at: format_timestamp(item.updated_at) + } + end + end +end diff --git a/lib/generators/mcp/crud/templates/show_tool_test.rb.tt b/lib/generators/mcp/crud/templates/show_tool_test.rb.tt new file mode 100644 index 0000000..24c96a6 --- /dev/null +++ b/lib/generators/mcp/crud/templates/show_tool_test.rb.tt @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module <%= plural_class_name %> + class Show<%= model_class %>ToolTest < McpToolTestCase + setup do + @user = users(:one) + # @<%= singular_name %> = <%= plural_name %>(:one) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(<%= plural_class_name %>::Show<%= model_class %>Tool, id: "test") + end + assert_match(/Authentication required/, error.message) + end + + test "returns error for non-existent <%= singular_name %>" do + mock_mcp_request(user: @user) + + result = call_tool(<%= plural_class_name %>::Show<%= model_class %>Tool, id: "nonexistent") + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + end +end diff --git a/lib/generators/mcp/crud/templates/update_tool.rb.tt b/lib/generators/mcp/crud/templates/update_tool.rb.tt new file mode 100644 index 0000000..bb6e988 --- /dev/null +++ b/lib/generators/mcp/crud/templates/update_tool.rb.tt @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module <%= plural_class_name %> + class Update<%= model_class %>Tool < ApplicationTool + description "Update a <%= humanized_name %>" + + annotations( + title: "Update <%= humanized_name %>", + read_only_hint: false, + open_world_hint: false + ) + + arguments do + required(:id).filled(:string).description("The <%= singular_name %> ID") + # Add optional arguments for update + # optional(:name).filled(:string).description("The <%= singular_name %> name") + end + + def call(id:, **args) + require_authentication! + + <%= singular_name %> = current_user.<%= plural_name %>.find_by(id: id) + return error_response("<%= humanized_name %> not found", code: "not_found") unless <%= singular_name %> + + with_current_user do + <%= singular_name %>.update!(args.compact) + end + + success_response( + serialize_<%= singular_name %>(<%= singular_name %>), + message: "<%= humanized_name %> updated successfully" + ) + rescue ActiveRecord::RecordInvalid => e + error_response(e.message, code: "validation_error") + end + + private + + def serialize_<%= singular_name %>(item) + { + id: item.id, + updated_at: format_timestamp(item.updated_at) + } + end + end +end diff --git a/lib/generators/mcp/crud/templates/update_tool_test.rb.tt b/lib/generators/mcp/crud/templates/update_tool_test.rb.tt new file mode 100644 index 0000000..2a503d3 --- /dev/null +++ b/lib/generators/mcp/crud/templates/update_tool_test.rb.tt @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module <%= plural_class_name %> + class Update<%= model_class %>ToolTest < McpToolTestCase + setup do + @user = users(:one) + # @<%= singular_name %> = <%= plural_name %>(:one) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(<%= plural_class_name %>::Update<%= model_class %>Tool, id: "test") + end + assert_match(/Authentication required/, error.message) + end + + test "returns error for non-existent <%= singular_name %>" do + mock_mcp_request(user: @user) + + result = call_tool(<%= plural_class_name %>::Update<%= model_class %>Tool, id: "nonexistent") + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + end +end diff --git a/lib/generators/mcp/resource/resource_generator.rb b/lib/generators/mcp/resource/resource_generator.rb new file mode 100644 index 0000000..e08ced0 --- /dev/null +++ b/lib/generators/mcp/resource/resource_generator.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mcp + module Generators + class ResourceGenerator < Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + class_option :uri, type: :string, desc: "Custom URI for the resource" + + def create_resource_file + template "resource.rb.tt", File.join("app/resources/mcp", "#{file_name}_resource.rb") + end + + def create_test_file + template "resource_test.rb.tt", File.join("test/resources", "#{file_name}_resource_test.rb") + end + + private + + def resource_class_name + "#{class_name.gsub('::', '')}Resource" + end + + def resource_uri + options[:uri] || "app:///#{file_name.pluralize}" + end + + def humanized_name + file_name.humanize + end + end + end +end diff --git a/lib/generators/mcp/resource/templates/resource.rb.tt b/lib/generators/mcp/resource/templates/resource.rb.tt new file mode 100644 index 0000000..bf7ae58 --- /dev/null +++ b/lib/generators/mcp/resource/templates/resource.rb.tt @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Mcp + class <%= resource_class_name %> < ApplicationResource + uri "<%= resource_uri %>" + resource_name "<%= humanized_name %>" + description "<%= humanized_name %> resource" + mime_type "application/json" + + def content + return to_json({ error: "Not authenticated" }) unless authenticated? + + # TODO: Implement resource content + to_json({ message: "Not implemented" }) + end + end +end diff --git a/lib/generators/mcp/resource/templates/resource_test.rb.tt b/lib/generators/mcp/resource/templates/resource_test.rb.tt new file mode 100644 index 0000000..ca6269c --- /dev/null +++ b/lib/generators/mcp/resource/templates/resource_test.rb.tt @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +class <%= resource_class_name %>Test < McpResourceTestCase + setup do + @user = users(:one) + end + + test "returns error when not authenticated" do + result = parse_resource(call_resource(Mcp::<%= resource_class_name %>)) + + assert result[:error].present? + assert_match(/Not authenticated/, result[:error]) + end + + # TODO: Add test for authenticated access +end diff --git a/lib/generators/mcp/tool/templates/tool.rb.tt b/lib/generators/mcp/tool/templates/tool.rb.tt new file mode 100644 index 0000000..db0b380 --- /dev/null +++ b/lib/generators/mcp/tool/templates/tool.rb.tt @@ -0,0 +1,37 @@ +# frozen_string_literal: true +<% if namespace_module.present? %> +module <%= namespace_module %> +<% end %> + class <%= tool_class_name %> < ApplicationTool + description "<%= humanized_name %>" +<% if admin_only? %> + + admin_only! +<% end %> + + annotations( + title: "<%= humanized_name %>", + read_only_hint: true, + open_world_hint: false + ) + + arguments do + # Add your arguments here + # required(:id).filled(:string).description("The ID") + # optional(:limit).filled(:integer).description("Maximum results") + end + + def call +<% if admin_only? %> + require_admin! +<% else %> + require_authentication! +<% end %> + + # TODO: Implement <%= file_name %> + success_response({ message: "Not implemented" }) + end + end +<% if namespace_module.present? %> +end +<% end %> diff --git a/lib/generators/mcp/tool/templates/tool_test.rb.tt b/lib/generators/mcp/tool/templates/tool_test.rb.tt new file mode 100644 index 0000000..5288641 --- /dev/null +++ b/lib/generators/mcp/tool/templates/tool_test.rb.tt @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" +<% if namespace_module.present? %> +module <%= namespace_module %> +<% end %> + class <%= tool_class_name %>Test < McpToolTestCase + setup do + @user = users(:one) +<% if admin_only? %> + @admin = admins(:one) +<% end %> + end +<% if admin_only? %> + + test "requires admin authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(<%= full_tool_class_name %>) + end + assert_match(/Admin authentication required/, error.message) + end +<% else %> + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(<%= full_tool_class_name %>) + end + assert_match(/Authentication required/, error.message) + end +<% end %> + + # TODO: Add more tests for <%= file_name %> + end +<% if namespace_module.present? %> +end +<% end %> diff --git a/lib/generators/mcp/tool/tool_generator.rb b/lib/generators/mcp/tool/tool_generator.rb new file mode 100644 index 0000000..eb70c6d --- /dev/null +++ b/lib/generators/mcp/tool/tool_generator.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Mcp + module Generators + class ToolGenerator < Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + argument :actions, type: :array, default: [ "call" ], banner: "action action" + + class_option :admin, type: :boolean, default: false, desc: "Require admin authentication" + + def create_tool_file + template "tool.rb.tt", File.join("app/tools", class_path, "#{file_name}_tool.rb") + end + + def create_test_file + template "tool_test.rb.tt", File.join("test/tools", class_path, "#{file_name}_tool_test.rb") + end + + private + + def tool_class_name + "#{class_name.gsub('::', '')}Tool" + end + + def full_tool_class_name + if class_path.any? + "#{class_path.map(&:camelize).join('::')}::#{tool_class_name}" + else + tool_class_name + end + end + + def namespace_module + class_path.map(&:camelize).join("::") + end + + def admin_only? + options[:admin] + end + + def humanized_name + file_name.humanize + end + end + end +end diff --git a/lib/middleware/malicious_path_blocker.rb b/lib/middleware/malicious_path_blocker.rb index e063ebe..54ce4c1 100644 --- a/lib/middleware/malicious_path_blocker.rb +++ b/lib/middleware/malicious_path_blocker.rb @@ -54,6 +54,7 @@ def blocked_path?(path) def unknown_file_request?(path) return false unless path.match?(FILE_EXTENSION_PATTERN) + return false if path.start_with?("/rails/active_storage/") # Allow if a real file exists in public/ !File.exist?(File.join(@public_path, path)) diff --git a/lib/tasks/consolidate_teams.rake b/lib/tasks/consolidate_teams.rake new file mode 100644 index 0000000..f9c5465 --- /dev/null +++ b/lib/tasks/consolidate_teams.rake @@ -0,0 +1,42 @@ +namespace :teams do + desc "Consolidate teams by company: assign users to company-named teams and remove old personal teams" + task consolidate_by_company: :environment do + users = User.where.not(company: [ nil, "" ]) + puts "Processing #{users.count} users with company data..." + + created = 0 + assigned = 0 + + users.find_each do |user| + team = Team.find_by(name: user.company) + + unless team + team = Team.create!(name: user.company) + created += 1 + puts " Created team: #{user.company}" + end + + unless user.member_of?(team) + team.memberships.create!(user: user, role: "member") + assigned += 1 + puts " Assigned #{user.username} -> #{user.company}" + end + end + + # Remove old personal teams for users who now have a company team + removed = 0 + Team.where("name LIKE ?", "%'s Team").find_each do |personal_team| + member = personal_team.users.first + next unless member&.company.present? + + company_team = Team.find_by(name: member.company) + next unless company_team && member.member_of?(company_team) + + personal_team.destroy! + removed += 1 + puts " Removed personal team: #{personal_team.name}" + end + + puts "\nDone! Created #{created} teams, assigned #{assigned} users, removed #{removed} personal teams." + end +end diff --git a/lib/tasks/counter_cache.rake b/lib/tasks/counter_cache.rake new file mode 100644 index 0000000..6776f55 --- /dev/null +++ b/lib/tasks/counter_cache.rake @@ -0,0 +1,32 @@ +namespace :counter_cache do + desc "Populate all counter cache columns" + task populate: :environment do + puts "Populating counter caches..." + + # Update chats messages_count and total_cost + puts "Updating chats..." + Chat.find_each do |chat| + chat.update_columns( + messages_count: chat.messages.count, + total_cost: chat.messages.sum(:cost) + ) + end + + # Update models chats_count and total_cost + puts "Updating models..." + Model.find_each do |model| + model.update_columns( + chats_count: model.chats.count, + total_cost: model.chats.sum(:total_cost) + ) + end + + # Update users total_cost + puts "Updating users..." + User.find_each do |user| + user.update_column(:total_cost, user.chats.sum(:total_cost)) + end + + puts "Done!" + end +end diff --git a/lib/tasks/mcp.rake b/lib/tasks/mcp.rake new file mode 100644 index 0000000..6058708 --- /dev/null +++ b/lib/tasks/mcp.rake @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +namespace :mcp do + # Controllers that are UI-only and don't need MCP equivalents + EXCLUDED_CONTROLLERS = %w[ + sessions + admins/sessions + home + ].freeze + + desc "Check MCP parity - list controllers without matching MCP tools" + task parity: :environment do + puts "Checking MCP parity...\n\n" + + # Get all controllers (excluding concerns, application, madmin, and UI-only) + controller_files = Dir.glob(Rails.root.join("app/controllers/**/*_controller.rb")) + controllers = controller_files.map do |f| + relative = f.sub(Rails.root.join("app/controllers/").to_s, "") + next if relative.start_with?("concerns/", "madmin/") + next if relative == "application_controller.rb" + + controller_name = relative.sub("_controller.rb", "") + next if EXCLUDED_CONTROLLERS.include?(controller_name) + + controller_name + end.compact + + # Get all MCP tools + tool_files = Dir.glob(Rails.root.join("app/tools/**/*_tool.rb")) + tools = tool_files.map do |f| + relative = f.sub(Rails.root.join("app/tools/").to_s, "") + next if relative == "application_tool.rb" + + relative.sub("_tool.rb", "") + end.compact + + # Map controllers to their likely MCP tools + coverage = {} + controllers.each do |controller| + parts = controller.split("/") + resource = parts.last.singularize + + # Look for matching tools + matching_tools = tools.select { |t| t.include?(resource) || t.include?(parts.last) } + coverage[controller] = matching_tools + end + + # Report + puts "Controller → MCP Tool Coverage\n" + puts "=" * 50 + + covered = 0 + uncovered = [] + + coverage.each do |controller, matching_tools| + if matching_tools.any? + covered += 1 + puts "✅ #{controller}" + matching_tools.each { |t| puts " └─ #{t}" } + else + uncovered << controller + end + end + + puts "\n" + if uncovered.any? + puts "❌ Controllers without MCP tools:" + uncovered.each { |c| puts " - #{c}" } + end + + puts "\n" + puts "Summary: #{covered}/#{coverage.size} controllers have MCP tools" + + if uncovered.any? + puts "\nTo add MCP tools for uncovered controllers:" + puts " bin/rails generate mcp:crud " + end + end + + desc "List all registered MCP tools" + task tools: :environment do + puts "Registered MCP Tools:\n\n" + + tools = ApplicationTool.descendants.sort_by(&:name) + + tools.each do |tool| + admin = tool.respond_to?(:admin_only?) && tool.admin_only? ? " [ADMIN]" : "" + puts " #{tool.name}#{admin}" + puts " #{tool.description}" if tool.respond_to?(:description) && tool.description + puts "" + end + + puts "Total: #{tools.size} tools" + end + + desc "List all registered MCP resources" + task resources: :environment do + puts "Registered MCP Resources:\n\n" + + resources = ApplicationResource.descendants.sort_by(&:name) + + resources.each do |resource| + puts " #{resource.name}" + puts " URI: #{resource.uri}" if resource.respond_to?(:uri) && resource.uri + puts " #{resource.description}" if resource.respond_to?(:description) && resource.description + puts "" + end + + puts "Total: #{resources.size} resources" + end +end diff --git a/lib/tasks/oauth.rake b/lib/tasks/oauth.rake index f0435ef..d9063ba 100644 --- a/lib/tasks/oauth.rake +++ b/lib/tasks/oauth.rake @@ -18,71 +18,48 @@ namespace :oauth do puts " Homepage URL: https://your-domain.com" puts " Callback URL: https://your-domain.com/users/auth/github/callback" - puts "\n4. Add credentials to Rails:" - puts " For development: rails credentials:edit --environment development" - puts " For production: rails credentials:edit --environment production" - puts " For test: rails credentials:edit --environment test" + puts "\n4. Add credentials via Madmin admin panel:" + puts " Go to /madmin/settings and fill in the GitHub section" - puts "\n Add this structure:" - puts " github:" - puts " client_id: your_client_id" - puts " client_secret: your_client_secret" - puts " openai:" - puts " api_key: your_openai_key (optional)" - - puts "\n✅ Done! Start your server with 'rails server'\n" + puts "\n✅ Done! Start your server with 'bin/dev'\n" end desc "Test OAuth configuration" task test: :environment do puts "\n🔍 Testing OAuth Configuration...\n" - client_id = Rails.application.credentials.dig(:github, :client_id) - client_secret = Rails.application.credentials.dig(:github, :client_secret) - openai_key = Rails.application.credentials.dig(:openai, :api_key) + client_id = Setting.get(:github_whyruby_client_id) + client_secret = Setting.get(:github_whyruby_client_secret) + api_token = Setting.get(:github_api_token) puts "Environment: #{Rails.env}" - puts "Credentials file: config/credentials/#{Rails.env}.yml.enc" if client_id.present? && client_secret.present? - puts "✅ GitHub OAuth is configured!" - puts " Client ID: #{client_id[0..7]}..." if client_id + puts "✅ GitHub OAuth (WhyRuby) is configured!" + puts " Client ID: #{client_id[0..7]}..." else - puts "❌ GitHub OAuth is NOT configured!" + puts "❌ GitHub OAuth (WhyRuby) is NOT configured!" puts " Missing: #{'Client ID' unless client_id.present?} #{'Client Secret' unless client_secret.present?}" - puts "\n Run 'rails oauth:setup' for instructions" + puts "\n Configure at /madmin/settings" end - if openai_key.present? - puts "✅ OpenAI API is configured!" - puts " API Key: #{openai_key[0..7]}..." + community_id = Setting.get(:github_rubycommunity_client_id) + community_secret = Setting.get(:github_rubycommunity_client_secret) + + if community_id.present? && community_secret.present? + puts "✅ GitHub OAuth (RubyCommunity) is configured!" + puts " Client ID: #{community_id[0..7]}..." else - puts "⚠️ OpenAI API is NOT configured (optional)" + puts "⚠️ GitHub OAuth (RubyCommunity) is NOT configured (optional for development)" end - puts "\nTo edit credentials:" - puts " rails credentials:edit --environment #{Rails.env}" - end - - desc "Show example credentials structure" - task example: :environment do - puts "\n📝 Example Rails Credentials Structure\n" - puts "="*50 - puts <<~YAML - # config/credentials/development.yml.enc - # (or production.yml.enc, test.yml.enc) - - github: - client_id: your_github_oauth_client_id_here - client_secret: your_github_oauth_client_secret_here - - openai: - api_key: sk-your_openai_api_key_here - - # You can also add other credentials here: - secret_key_base: generated_secret_key_base - YAML + if api_token.present? + puts "✅ GitHub API Token is configured!" + puts " Token: #{api_token[0..7]}..." + else + puts "⚠️ GitHub API Token is NOT configured (needed for batch sync)" + end - puts "\nTo edit: rails credentials:edit --environment #{Rails.env}" + puts "\nTo edit: /madmin/settings" end end diff --git a/lib/tasks/oauth_debug.rake b/lib/tasks/oauth_debug.rake index bf8c800..ad8e684 100644 --- a/lib/tasks/oauth_debug.rake +++ b/lib/tasks/oauth_debug.rake @@ -4,39 +4,38 @@ namespace :oauth do puts "\n🔍 OAuth Debug Information\n" puts "="*50 - puts "\n1. GitHub Credentials:" - puts " Client ID: #{Rails.application.credentials.dig(:github, :client_id)}" - puts " Client Secret: #{Rails.application.credentials.dig(:github, :client_secret).present? ? '[PRESENT]' : '[MISSING]'}" + puts "\n1. GitHub Credentials (from Settings):" + puts " WhyRuby Client ID: #{Setting.get(:github_whyruby_client_id).present? ? '[PRESENT]' : '[MISSING]'}" + puts " WhyRuby Client Secret: #{Setting.get(:github_whyruby_client_secret).present? ? '[PRESENT]' : '[MISSING]'}" + puts " RubyCommunity Client ID: #{Setting.get(:github_rubycommunity_client_id).present? ? '[PRESENT]' : '[MISSING]'}" + puts " RubyCommunity Client Secret: #{Setting.get(:github_rubycommunity_client_secret).present? ? '[PRESENT]' : '[MISSING]'}" + puts " API Token: #{Setting.get(:github_api_token).present? ? '[PRESENT]' : '[MISSING]'}" - puts "\n2. Devise Configuration:" - puts " Omniauth providers: #{Devise.omniauth_providers}" - puts " Omniauth path prefix: #{Devise.omniauth_path_prefix}" - - puts "\n3. Routes:" + puts "\n2. Routes:" routes = Rails.application.routes.routes.select { |r| r.path.spec.to_s.include?("github") } routes.each do |route| puts " #{route.verb.ljust(8)} #{route.path.spec.to_s.ljust(35)} => #{route.defaults[:controller]}##{route.defaults[:action]}" end - puts "\n4. Middleware Stack (Omniauth related):" + puts "\n3. Middleware Stack (Omniauth related):" Rails.application.config.middleware.each do |middleware| if middleware.to_s.include?("OmniAuth") || middleware.to_s.include?("Warden") puts " ✓ #{middleware}" end end - puts "\n5. Test URLs for port 3003:" - puts " Sign in: http://localhost:3003/users/auth/github" - puts " Callback: http://localhost:3003/users/auth/github/callback" + puts "\n4. Test URLs for port 3003:" + puts " Sign in: http://localhost:3003/auth/github" + puts " Callback: http://localhost:3003/auth/github/callback" - puts "\n6. Common Issues:" + puts "\n5. Common Issues:" puts " ❌ 'Authentication passthru' = Middleware not loaded (restart server)" puts " ❌ 'Redirect mismatch' = Check GitHub app callback URL" puts " ❌ 'CSRF token' = Check session store configuration" puts "\n✅ Next steps:" - puts " 1. Ensure GitHub OAuth app uses: http://localhost:3003/users/auth/github/callback" - puts " 2. Restart Rails server: rails server -p 3003" + puts " 1. Ensure GitHub OAuth app uses: http://localhost:3003/auth/github/callback" + puts " 2. Restart Rails server: bin/dev" puts " 3. Clear browser cookies for localhost" puts " 4. Try signing in again\n" end diff --git a/lib/tasks/og_image.rake b/lib/tasks/og_image.rake new file mode 100644 index 0000000..399c129 --- /dev/null +++ b/lib/tasks/og_image.rake @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +namespace :og_image do + desc "Generate OG image by screenshotting /og-image route (requires Playwright)" + task generate: :environment do + require "open3" + + port = 3000 + url = "http://localhost:#{port}/og-image" + output_path = Rails.root.join("public", "og-image.png") + + puts "Generating OG image from #{url}..." + + begin + require "net/http" + Net::HTTP.get_response(URI(url)) + rescue Errno::ECONNREFUSED + puts "Error: Server not running on port #{port}. Start with: bin/dev" + exit 1 + end + + script = <<~JS + const { chromium } = require('playwright'); + (async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + await page.setViewportSize({ width: 1200, height: 630 }); + await page.goto('#{url}'); + await page.waitForLoadState('networkidle'); + await page.screenshot({ path: '#{output_path}' }); + await browser.close(); + console.log('Screenshot saved to #{output_path}'); + })(); + JS + + _, _, status = Open3.capture3("npx playwright --version") + + unless status.success? + puts "Playwright not found. Run: npx playwright install" + puts "" + puts "Or generate manually — see: rake og_image:instructions" + exit 1 + end + + stdout, stderr, status = Open3.capture3("node", "-e", script) + + if status.success? + puts stdout + puts "OG image saved to #{output_path}" + else + puts "Error: #{stderr}" + exit 1 + end + end + + desc "Show instructions for manual OG image generation" + task instructions: :environment do + puts <<~TEXT + Manual OG Image Generation + ========================== + + 1. Start server: bin/dev + 2. Open: http://localhost:3000/og-image + 3. DevTools (F12) → device toolbar → 1200 x 630 + 4. Right-click → "Capture screenshot" + 5. Save as: public/og-image.png + + Per-page OG images + ================== + + Any view can override the defaults: + + <% content_for :og_title, @post.title %> + <% content_for :og_description, @post.excerpt %> + <% content_for :og_image, "/og-images/posts/123.png" %> + + The layout automatically picks up these values. + + Verify + ====== + + Facebook: https://developers.facebook.com/tools/debug/ + Twitter: https://cards-dev.twitter.com/validator + LinkedIn: https://www.linkedin.com/post-inspector/ + + TEXT + end +end diff --git a/lib/tasks/production_data.rake b/lib/tasks/production_data.rake new file mode 100644 index 0000000..4bfbf7c --- /dev/null +++ b/lib/tasks/production_data.rake @@ -0,0 +1,153 @@ +namespace :production do + namespace :pull do + desc "Pull production database to development" + task db: :environment do + config = ProductionPullConfig.new + db_puller = ProductionDbPuller.new(config) + db_puller.call + end + + desc "Pull production ActiveStorage files to development" + task files: :environment do + config = ProductionPullConfig.new + files_puller = ProductionFilesPuller.new(config) + files_puller.call + end + end + + desc "Pull production database and files to development" + task pull: :environment do + Rake::Task["production:pull:db"].invoke + Rake::Task["production:pull:files"].invoke + end +end + +class ProductionPullConfig + attr_reader :server_ip, :volume_name, :ssh_user, :dev_db_path, :storage_root + + def initialize + deploy = YAML.safe_load_file(Rails.root.join("config/deploy.yml"), permitted_classes: [ Symbol ]) + db_config = YAML.safe_load(ERB.new(File.read(Rails.root.join("config/database.yml"))).result, permitted_classes: [ Symbol ]) + + @server_ip = extract_server_ip(deploy) + @volume_name = extract_volume_name(deploy) + @ssh_user = ENV.fetch("DEPLOY_SSH_USER", "root") + @dev_db_path = Rails.root.join(db_config.dig("development", "database")) + @storage_root = Rails.root.join("storage") + end + + def remote_volume_path + "/var/lib/docker/volumes/#{volume_name}/_data" + end + + def remote_db_path + "#{remote_volume_path}/production.sqlite3" + end + + def ssh_target + "#{ssh_user}@#{server_ip}" + end + + private + + def extract_server_ip(deploy) + servers = deploy.fetch("servers") + hosts = servers.is_a?(Array) ? servers : servers.fetch("web") + hosts.first + end + + def extract_volume_name(deploy) + volume_entry = deploy.fetch("volumes").first + volume_entry.split(":").first + end +end + +class ProductionDbPuller + def initialize(config) + @config = config + end + + def call + confirm_overwrite! + download_production_db + backup_dev_db + restore_production_to_dev + run_migrations + print_summary + end + + private + + def confirm_overwrite! + return if ENV["CONFIRM"] == "yes" + + print "\n⚠️ This will replace your development database with production data. Continue? [y/N] " + answer = $stdin.gets.chomp + abort "Aborted." unless answer.downcase == "y" + end + + def download_production_db + puts "\n→ Downloading production database..." + @tmp_file = Rails.root.join("tmp/production.sqlite3") + FileUtils.mkdir_p(Rails.root.join("tmp")) + + sh "scp #{@config.ssh_target}:#{@config.remote_db_path} #{@tmp_file}" + puts " Downloaded to #{@tmp_file}" + end + + def backup_dev_db + return unless File.exist?(@config.dev_db_path) + + backup_path = "#{@config.dev_db_path}.backup" + puts "\n→ Backing up development database to #{backup_path}" + FileUtils.cp(@config.dev_db_path, backup_path) + end + + def restore_production_to_dev + puts "\n→ Restoring production database to development..." + FileUtils.rm_f(@config.dev_db_path) + + sh "sqlite3 #{@tmp_file} .dump | sqlite3 #{@config.dev_db_path}" + FileUtils.rm_f(@tmp_file) + puts " Restored successfully" + end + + def run_migrations + puts "\n→ Running pending migrations..." + Rake::Task["db:migrate"].invoke + end + + def print_summary + puts "\n✓ Development database replaced with production data" + puts "\n Record counts:" + ActiveRecord::Base.connection.tables.sort.each do |table| + next if table.start_with?("ar_internal_metadata", "schema_migrations", "solid_") + + count = ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM #{table}") + puts " #{table}: #{count}" + end + end + + def sh(command) + system(command) || abort("Command failed: #{command}") + end +end + +class ProductionFilesPuller + def initialize(config) + @config = config + end + + def call + puts "\n→ Syncing production storage files..." + sh "rsync -az --exclude='*.sqlite3*' --exclude='.keep' " \ + "#{@config.ssh_target}:#{@config.remote_volume_path}/ #{@config.storage_root}/" + puts "\n✓ Storage files synced to #{@config.storage_root}" + end + + private + + def sh(command) + system(command) || abort("Command failed: #{command}") + end +end diff --git a/lib/tasks/reprocess_images.rake b/lib/tasks/reprocess_images.rake index 7f431fb..b31f1cb 100644 --- a/lib/tasks/reprocess_images.rake +++ b/lib/tasks/reprocess_images.rake @@ -17,17 +17,10 @@ namespace :images do next end - blob = post.featured_image.blob - # Process the image to generate variants - processor = ImageProcessor.new(blob) - result = processor.process! + result = post.process_image_variants! if result[:success] - post.update_columns( - image_blur_data: result[:blur_data], - image_variants: result[:variants] - ) processed_count += 1 print "." # Progress indicator else @@ -70,14 +63,9 @@ namespace :images do end # Reprocess with new settings - processor = ImageProcessor.new(post.featured_image) - result = processor.process! + result = post.process_image_variants! if result[:success] - post.update_columns( - image_blur_data: result[:blur_data], - image_variants: result[:variants] - ) processed += 1 puts "✓" else diff --git a/lib/tasks/update_image_variants.rake b/lib/tasks/update_image_variants.rake index bcd6fd1..1618c8c 100644 --- a/lib/tasks/update_image_variants.rake +++ b/lib/tasks/update_image_variants.rake @@ -23,11 +23,9 @@ namespace :images do print "Processing post #{index + 1}/#{total} (#{post.title[0..30]}...)... " # Reprocess the image with new variants - processor = ImageProcessor.new(post.featured_image) - result = processor.process! + result = post.process_image_variants! if result[:success] - post.update_columns(image_variants: result[:variants]) puts "✓" else puts "✗ (#{result[:error]})" @@ -51,11 +49,9 @@ namespace :images do print "Processing post #{index + 1}/#{total} (#{post.title[0..30]}...)... " - processor = ImageProcessor.new(post.featured_image) - result = processor.process! + result = post.process_image_variants! if result[:success] - post.update_columns(image_variants: result[:variants]) puts "✓" else puts "✗ (#{result[:error]})" diff --git a/lib/templates/active_record/migration/create_table_migration.rb.tt b/lib/templates/active_record/migration/create_table_migration.rb.tt index 358bced..9368191 100644 --- a/lib/templates/active_record/migration/create_table_migration.rb.tt +++ b/lib/templates/active_record/migration/create_table_migration.rb.tt @@ -1,7 +1,6 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] def change - create_table :<%= table_name %>, force: true, id: false do |t| - t.primary_key :id, :string, default: -> { "ULID()" } + create_table :<%= table_name %>, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| <% attributes.each do |attribute| -%> <% if attribute.password_digest? -%> t.string :password_digest<%= attribute.inject_options %> diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100755 index 0000000..f613886 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/controllers/onboardings_controller_test.rb b/test/controllers/onboardings_controller_test.rb new file mode 100644 index 0000000..df8f796 --- /dev/null +++ b/test/controllers/onboardings_controller_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class OnboardingsControllerTest < ActionDispatch::IntegrationTest + test "redirects to login when not authenticated" do + get onboarding_path + assert_redirected_to github_auth_with_return_path + end + + test "shows onboarding form for un-onboarded user" do + user = users(:user_no_testimonial) + sign_in(user) + + get onboarding_path + assert_response :success + end + + test "redirects onboarded users away from onboarding" do + user = users(:user_with_testimonial) + sign_in(user) + + get onboarding_path + assert_redirected_to root_path + end + + test "completes onboarding with name" do + user = users(:user_no_testimonial) + team = teams(:one) + sign_in(user) + + patch onboarding_path, params: { onboarding: { name: "Alice Smith", team_name: "Alice's Team" } } + + user.reload + team.reload + assert_equal "Alice Smith", user.name + assert user.onboarded? + assert_redirected_to team_root_path(team) + end + + test "updates team name when user is owner" do + user = users(:user_no_testimonial) + team = teams(:one) + sign_in(user) + + patch onboarding_path, params: { onboarding: { name: "Alice", team_name: "New Team Name" } } + + team.reload + assert_equal "New Team Name", team.name + end + + test "renders form again when name is blank" do + user = users(:user_no_testimonial) + sign_in(user) + + patch onboarding_path, params: { onboarding: { name: "" } } + + # Blank name is normalized to nil; the controller redirects + # but the user remains un-onboarded (name.present? is false) + assert_response :redirect + user.reload + assert_nil user.name + end +end diff --git a/test/controllers/posts_controller_test.rb b/test/controllers/posts_controller_test.rb new file mode 100644 index 0000000..149c96a --- /dev/null +++ b/test/controllers/posts_controller_test.rb @@ -0,0 +1,114 @@ +require "test_helper" + +class PostsControllerTest < ActionDispatch::IntegrationTest + setup do + @owner = users(:user_with_testimonial) + @other_user = users(:user_without_testimonial) + @admin = users(:admin_user) + @category = categories(:general) + @published_post = posts(:published_article) + @unpublished_post = posts(:unpublished_article) + @other_users_post = posts(:other_users_post) + end + + # ------------------------------------------------------------------------- + # Authorization: edit + # ------------------------------------------------------------------------- + + test "owner can access edit for their post" do + sign_in @owner + get edit_post_path(@published_post) + assert_response :success + end + + test "other user cannot edit someone else's post" do + sign_in @other_user + get edit_post_path(@published_post) + assert_redirected_to root_path + end + + test "admin can edit any post" do + sign_in @admin + get edit_post_path(@other_users_post) + assert_response :success + end + + test "unauthenticated user is redirected when editing" do + get edit_post_path(@published_post) + assert_response :redirect + end + + # ------------------------------------------------------------------------- + # Authorization: destroy + # ------------------------------------------------------------------------- + + test "owner can destroy their own post" do + sign_in @owner + assert_difference "Post.count", -1 do + delete post_destroy_path(@published_post) + end + end + + test "other user cannot destroy someone else's post" do + sign_in @other_user + assert_no_difference "Post.count" do + delete post_destroy_path(@published_post) + end + assert_redirected_to root_path + end + + test "admin can destroy any post" do + sign_in @admin + assert_difference "Post.count", -1 do + delete post_destroy_path(@other_users_post) + end + end + + # ------------------------------------------------------------------------- + # Authorization: create + # ------------------------------------------------------------------------- + + test "unauthenticated user cannot create a post" do + assert_no_difference "Post.count" do + post posts_path, params: { + post: { + title: "New Post", content: "Content here.", + post_type: "article", category_id: @category.id + } + } + end + assert_response :redirect + end + + # ------------------------------------------------------------------------- + # Show: published/unpublished visibility + # ------------------------------------------------------------------------- + + test "published post is visible to any visitor" do + get post_path(@published_post.category, @published_post) + assert_response :success + end + + test "unpublished post is visible to its owner" do + sign_in @owner + get post_path(@unpublished_post.category, @unpublished_post) + assert_response :success + end + + test "unpublished post redirects other logged-in users" do + sign_in @other_user + get post_path(@unpublished_post.category, @unpublished_post) + assert_redirected_to root_path + end + + test "unpublished post redirects unauthenticated visitors" do + get post_path(@unpublished_post.category, @unpublished_post) + assert_redirected_to root_path + end + + test "admin can view unpublished posts" do + sign_in @admin + get post_path(@unpublished_post.category, @unpublished_post) + assert_response :success + end +end diff --git a/test/controllers/teams/languages_controller_test.rb b/test/controllers/teams/languages_controller_test.rb new file mode 100644 index 0000000..461b2b5 --- /dev/null +++ b/test/controllers/teams/languages_controller_test.rb @@ -0,0 +1,60 @@ +require "test_helper" + +class Teams::LanguagesControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:user_with_testimonial) + @team = teams(:one) + sign_in(@user) + end + + test "index shows active team languages" do + get team_languages_path(@team) + assert_response :success + assert_select "span", text: "English" + end + + test "create adds language to team" do + french = languages(:french) + + assert_difference -> { @team.team_languages.count }, 1 do + post team_languages_path(@team), params: { language_id: french.id } + end + + assert_redirected_to team_languages_path(@team) + assert_includes @team.active_language_codes, "fr" + end + + test "create triggers backfill job" do + french = languages(:french) + + assert_enqueued_with(job: BackfillTranslationsJob) do + post team_languages_path(@team), params: { language_id: french.id } + end + end + + test "destroy deactivates language" do + spanish = languages(:spanish) + + delete team_language_path(@team, spanish) + + assert_redirected_to team_languages_path(@team) + assert_not_includes @team.active_language_codes, "es" + end + + test "destroy prevents removing last language" do + # Remove Spanish first so English is the only one left + delete team_language_path(@team, languages(:spanish)) + + delete team_language_path(@team, languages(:english)) + + assert_redirected_to team_languages_path(@team) + assert_includes @team.active_language_codes, "en" + end + + test "non-admin cannot access languages" do + # user_one is a member (not admin) of team_two + sign_in(users(:user_with_testimonial)) + get team_languages_path(teams(:two)) + assert_response :redirect + end +end diff --git a/test/controllers/teams/subscription_cancellations_controller_test.rb b/test/controllers/teams/subscription_cancellations_controller_test.rb new file mode 100644 index 0000000..3cee03f --- /dev/null +++ b/test/controllers/teams/subscription_cancellations_controller_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +class Teams::SubscriptionCancellationsControllerTest < ActionDispatch::IntegrationTest + setup do + @team = teams(:one) + @admin = users(:user_with_testimonial) + end + + test "redirects when not authenticated" do + post team_subscription_cancellation_path(@team) + assert_response :redirect + end + + test "redirects non-admin members on create" do + sign_in(users(:user_with_testimonial)) + # user_one is member (not admin) of team_two + post team_subscription_cancellation_path(teams(:two)) + assert_redirected_to team_root_path(teams(:two)) + end + + test "create cancels subscription and redirects to billing" do + @team.update!(stripe_subscription_id: "sub_test123", subscription_status: "active") + sign_in(@admin) + + stub_stripe_cancel do + post team_subscription_cancellation_path(@team) + end + + assert_redirected_to team_billing_path(@team) + assert flash[:notice].present? + end + + test "redirects non-admin members on destroy" do + sign_in(users(:user_with_testimonial)) + delete team_subscription_cancellation_path(teams(:two)) + assert_redirected_to team_root_path(teams(:two)) + end + + test "destroy resumes subscription and redirects to billing" do + @team.update!(stripe_subscription_id: "sub_test123", subscription_status: "active", cancel_at_period_end: true) + sign_in(@admin) + + stub_stripe_resume do + delete team_subscription_cancellation_path(@team) + end + + assert_redirected_to team_billing_path(@team) + assert flash[:notice].present? + end + + + def stub_stripe_cancel(&block) + stub_stripe_subscription_update(cancel_at_period_end: true, &block) + end + + def stub_stripe_resume(&block) + stub_stripe_subscription_update(cancel_at_period_end: false, &block) + end + + def stub_stripe_subscription_update(cancel_at_period_end:) + items = Struct.new(:data).new([ { "current_period_end" => 1700000000 } ]) + subscription = Struct.new(:status, :cancel_at_period_end, :items) + .new("active", cancel_at_period_end, items) + + original_update = Stripe::Subscription.method(:update) + original_retrieve = Stripe::Subscription.method(:retrieve) + + Stripe::Subscription.define_singleton_method(:update) { |*, **| subscription } + Stripe::Subscription.define_singleton_method(:retrieve) { |*, **| subscription } + + yield + ensure + Stripe::Subscription.define_singleton_method(:update, original_update) + Stripe::Subscription.define_singleton_method(:retrieve, original_retrieve) + end +end diff --git a/test/controllers/testimonials_controller_test.rb b/test/controllers/testimonials_controller_test.rb index 6fbfc52..526d5b4 100644 --- a/test/controllers/testimonials_controller_test.rb +++ b/test/controllers/testimonials_controller_test.rb @@ -1,8 +1,6 @@ require "test_helper" class TestimonialsControllerTest < ActionDispatch::IntegrationTest - include Devise::Test::IntegrationHelpers - test "unauthenticated user cannot create testimonial" do post testimonial_path, params: { testimonial: { quote: "I love Ruby!" } } assert_response :redirect diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb new file mode 100644 index 0000000..5b2c4f2 --- /dev/null +++ b/test/controllers/users_controller_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class UsersControllerTest < ActionDispatch::IntegrationTest + setup do + @public_user = users(:user_with_testimonial) + @private_user = users(:user_without_testimonial) + + # Make user_without_testimonial a private profile + @private_user.update_columns(public: false) + # Ensure the other is public + @public_user.update_columns(public: true) + end + + # ------------------------------------------------------------------------- + # Profile visibility + # ------------------------------------------------------------------------- + + test "public profile is visible to anyone" do + get user_path(@public_user) + assert_response :success + end + + test "private profile redirects non-owner to community index" do + sign_in users(:admin_user) + get user_path(@private_user) + assert_redirected_to "/community" + end + + test "private profile is visible to the owner" do + sign_in @private_user + get user_path(@private_user) + assert_response :success + end + + test "private profile is visible without login if accessed by owner" do + # Unauthenticated user hitting a private profile gets redirected + get user_path(@private_user) + assert_redirected_to "/community" + end +end diff --git a/test/controllers/webhooks/stripe_controller_test.rb b/test/controllers/webhooks/stripe_controller_test.rb new file mode 100644 index 0000000..9edb30c --- /dev/null +++ b/test/controllers/webhooks/stripe_controller_test.rb @@ -0,0 +1,113 @@ +require "test_helper" + +class Webhooks::StripeControllerTest < ActionDispatch::IntegrationTest + test "returns bad_request for invalid signature" do + post webhooks_stripe_path, + params: "{}", + headers: { + "CONTENT_TYPE" => "application/json", + "HTTP_STRIPE_SIGNATURE" => "invalid" + } + assert_response :bad_request + end + + test "handles checkout.session.completed event" do + team = teams(:one) + team.update!(stripe_customer_id: "cus_test123") + + event_data = { + type: "checkout.session.completed", + data: { + object: { + customer: "cus_test123", + subscription: "sub_test456" + } + } + } + event = Stripe::Event.construct_from(event_data) + + stub_webhook_and_post(event, event_data) do + # Also stub the sync call + team.define_singleton_method(:sync_subscription_from_stripe!) do + update!(subscription_status: "active", current_period_ends_at: Time.utc(2026, 3, 1)) + end + Team.define_singleton_method(:find_by) do |**args| + args[:stripe_customer_id] == "cus_test123" ? team : nil + end + end + + team.reload + assert_equal "sub_test456", team.stripe_subscription_id + ensure + Team.singleton_class.remove_method(:find_by) if Team.singleton_class.method_defined?(:find_by, false) + end + + test "handles customer.subscription.updated event" do + team = teams(:one) + team.update!(stripe_customer_id: "cus_test123", stripe_subscription_id: "sub_test456") + + event_data = { + type: "customer.subscription.updated", + data: { + object: { + id: "sub_test456", + customer: "cus_test123", + status: "past_due", + current_period_end: 1700000000 + } + } + } + event = Stripe::Event.construct_from(event_data) + + stub_webhook_and_post(event, event_data) do + team.define_singleton_method(:sync_subscription_from_stripe!) do + update!(subscription_status: "past_due", current_period_ends_at: Time.at(1700000000).utc) + end + Team.define_singleton_method(:find_by) do |**args| + args[:stripe_customer_id] == "cus_test123" ? team : nil + end + end + + team.reload + assert_equal "past_due", team.subscription_status + ensure + Team.singleton_class.remove_method(:find_by) if Team.singleton_class.method_defined?(:find_by, false) + end + + test "handles customer.subscription.deleted event" do + team = teams(:one) + team.update!(stripe_customer_id: "cus_test123", stripe_subscription_id: "sub_test456", subscription_status: "active") + + event_data = { + type: "customer.subscription.deleted", + data: { + object: { + customer: "cus_test123" + } + } + } + event = Stripe::Event.construct_from(event_data) + + stub_webhook_and_post(event, event_data) + + team.reload + assert_equal "canceled", team.subscription_status + assert_nil team.stripe_subscription_id + end + + private + + def stub_webhook_and_post(event, event_data) + original = Stripe::Webhook.method(:construct_event) + Stripe::Webhook.define_singleton_method(:construct_event) { |*, **| event } + + yield if block_given? + + post webhooks_stripe_path, + params: event_data.to_json, + headers: { "CONTENT_TYPE" => "application/json", "HTTP_STRIPE_SIGNATURE" => "valid" } + assert_response :ok + ensure + Stripe::Webhook.define_singleton_method(:construct_event, original) + end +end diff --git a/test/fixtures/admins.yml b/test/fixtures/admins.yml new file mode 100644 index 0000000..a0f3c22 --- /dev/null +++ b/test/fixtures/admins.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +# +# Use hardcoded UUIDv7s to maintain referential integrity across fixtures. + +one: + id: 01961a2a-c0de-7000-8000-000000000101 + email: admin1@example.com + +two: + id: 01961a2a-c0de-7000-8000-000000000102 + email: admin2@example.com diff --git a/test/fixtures/articles.yml b/test/fixtures/articles.yml new file mode 100644 index 0000000..fd355b2 --- /dev/null +++ b/test/fixtures/articles.yml @@ -0,0 +1,15 @@ +# Use hardcoded UUIDv7s to maintain referential integrity across fixtures. + +one: + id: 01961a2a-c0de-7000-8000-000000001001 + team_id: 01961a2a-c0de-7000-8000-000000000501 + user_id: 01961a2a-c0de-7000-8000-000000000101 + title: First Article + body: This is the body of the first article. + +two: + id: 01961a2a-c0de-7000-8000-000000001002 + team_id: 01961a2a-c0de-7000-8000-000000000501 + user_id: 01961a2a-c0de-7000-8000-000000000101 + title: Second Article + body: This is the body of the second article. diff --git a/test/fixtures/categories.yml b/test/fixtures/categories.yml new file mode 100644 index 0000000..1606c0e --- /dev/null +++ b/test/fixtures/categories.yml @@ -0,0 +1,13 @@ +general: + id: "01961a2a-c0de-7000-8000-000000000201" + name: "General" + slug: "general" + position: 1 + is_success_story: false + +success_stories: + id: "01961a2a-c0de-7000-8000-000000000202" + name: "Success Stories" + slug: "success-stories" + position: 2 + is_success_story: true diff --git a/test/fixtures/chats.yml b/test/fixtures/chats.yml new file mode 100644 index 0000000..bd0a9d1 --- /dev/null +++ b/test/fixtures/chats.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +# +# Use hardcoded UUIDv7s and explicit _id columns for referential integrity. + +one: + id: 01961a2a-c0de-7000-8000-000000000301 + user_id: 01961a2a-c0de-7000-8000-000000000101 + team_id: 01961a2a-c0de-7000-8000-000000000501 + model_id: 01961a2a-c0de-7000-8000-000000000201 + +two: + id: 01961a2a-c0de-7000-8000-000000000302 + user_id: 01961a2a-c0de-7000-8000-000000000102 + team_id: 01961a2a-c0de-7000-8000-000000000502 + model_id: 01961a2a-c0de-7000-8000-000000000202 diff --git a/test/fixtures/languages.yml b/test/fixtures/languages.yml new file mode 100644 index 0000000..88f9081 --- /dev/null +++ b/test/fixtures/languages.yml @@ -0,0 +1,29 @@ +# Use hardcoded UUIDv7s to maintain referential integrity across fixtures. + +english: + id: 01961a2a-c0de-7000-8000-000000000801 + code: en + name: English + native_name: English + enabled: true + +spanish: + id: 01961a2a-c0de-7000-8000-000000000802 + code: es + name: Spanish + native_name: "Espa\u00f1ol" + enabled: true + +french: + id: 01961a2a-c0de-7000-8000-000000000803 + code: fr + name: French + native_name: "Fran\u00e7ais" + enabled: true + +disabled_lang: + id: 01961a2a-c0de-7000-8000-000000000804 + code: de + name: German + native_name: Deutsch + enabled: false diff --git a/test/fixtures/memberships.yml b/test/fixtures/memberships.yml new file mode 100644 index 0000000..8ee39ab --- /dev/null +++ b/test/fixtures/memberships.yml @@ -0,0 +1,28 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +# +# Use hardcoded UUIDv7s to maintain referential integrity across fixtures. + +user_one_team_one: + id: 01961a2a-c0de-7000-8000-000000000601 + user_id: 01961a2a-c0de-7000-8000-000000000101 + team_id: 01961a2a-c0de-7000-8000-000000000501 + role: owner + +user_two_team_two: + id: 01961a2a-c0de-7000-8000-000000000602 + user_id: 01961a2a-c0de-7000-8000-000000000102 + team_id: 01961a2a-c0de-7000-8000-000000000502 + role: owner + +user_one_team_two: + id: 01961a2a-c0de-7000-8000-000000000603 + user_id: 01961a2a-c0de-7000-8000-000000000101 + team_id: 01961a2a-c0de-7000-8000-000000000502 + invited_by_id: 01961a2a-c0de-7000-8000-000000000102 + role: member + +user_no_testimonial_team_one: + id: 01961a2a-c0de-7000-8000-000000000604 + user_id: 01961a2a-c0de-7000-8000-000000000104 + team_id: 01961a2a-c0de-7000-8000-000000000501 + role: owner diff --git a/test/fixtures/messages.yml b/test/fixtures/messages.yml new file mode 100644 index 0000000..bdd404b --- /dev/null +++ b/test/fixtures/messages.yml @@ -0,0 +1,22 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +# +# Use hardcoded UUIDv7s and explicit _id columns for referential integrity. +# Note: messages has circular FK with tool_calls - do NOT set tool_call_id here. + +user_message: + id: 01961a2a-c0de-7000-8000-000000000401 + chat_id: 01961a2a-c0de-7000-8000-000000000301 + role: user + content: "Hello, how are you?" + input_tokens: 10 + output_tokens: 0 + +assistant_message: + id: 01961a2a-c0de-7000-8000-000000000402 + chat_id: 01961a2a-c0de-7000-8000-000000000301 + model_id: 01961a2a-c0de-7000-8000-000000000201 + role: assistant + content: "I'm doing well, thank you for asking!" + input_tokens: 10 + output_tokens: 15 + cost: 0.0012 diff --git a/test/fixtures/mobility_string_translations.yml b/test/fixtures/mobility_string_translations.yml new file mode 100644 index 0000000..5367906 --- /dev/null +++ b/test/fixtures/mobility_string_translations.yml @@ -0,0 +1,21 @@ +# Mobility string translations for test fixtures + +article_one_title: + id: 01961a2a-c0de-7000-8000-000000002001 + locale: en + key: title + value: First Article + translatable_id: 01961a2a-c0de-7000-8000-000000001001 + translatable_type: Article + created_at: <%= 1.day.ago %> + updated_at: <%= 1.day.ago %> + +article_two_title: + id: 01961a2a-c0de-7000-8000-000000002002 + locale: en + key: title + value: Second Article + translatable_id: 01961a2a-c0de-7000-8000-000000001002 + translatable_type: Article + created_at: <%= 1.day.ago %> + updated_at: <%= 1.day.ago %> diff --git a/test/fixtures/mobility_text_translations.yml b/test/fixtures/mobility_text_translations.yml new file mode 100644 index 0000000..1f0747c --- /dev/null +++ b/test/fixtures/mobility_text_translations.yml @@ -0,0 +1,21 @@ +# Mobility text translations for test fixtures + +article_one_body: + id: 01961a2a-c0de-7000-8000-000000002101 + locale: en + key: body + value: This is the body of the first article. + translatable_id: 01961a2a-c0de-7000-8000-000000001001 + translatable_type: Article + created_at: <%= 1.day.ago %> + updated_at: <%= 1.day.ago %> + +article_two_body: + id: 01961a2a-c0de-7000-8000-000000002102 + locale: en + key: body + value: This is the body of the second article. + translatable_id: 01961a2a-c0de-7000-8000-000000001002 + translatable_type: Article + created_at: <%= 1.day.ago %> + updated_at: <%= 1.day.ago %> diff --git a/test/fixtures/models.yml b/test/fixtures/models.yml new file mode 100644 index 0000000..c3bcd22 --- /dev/null +++ b/test/fixtures/models.yml @@ -0,0 +1,33 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +# +# Use hardcoded UUIDv7s to maintain referential integrity across fixtures. + +gpt4: + id: 01961a2a-c0de-7000-8000-000000000201 + model_id: gpt-4 + name: GPT-4 + provider: openai + family: gpt-4 + context_window: 8192 + max_output_tokens: 4096 + pricing: + text_tokens: + standard: + input_per_million: 30 + output_per_million: 60 + cached_input_per_million: 15 + +claude: + id: 01961a2a-c0de-7000-8000-000000000202 + model_id: claude-3-opus-20240229 + name: Claude 3 Opus + provider: anthropic + family: claude-3 + context_window: 200000 + max_output_tokens: 4096 + pricing: + text_tokens: + standard: + input_per_million: 15 + output_per_million: 75 + cached_input_per_million: 7.5 diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml new file mode 100644 index 0000000..9a70a84 --- /dev/null +++ b/test/fixtures/posts.yml @@ -0,0 +1,65 @@ +published_article: + id: "01961a2a-c0de-7000-8000-000000000301" + title: "Why Ruby Rocks" + content: "Ruby is an amazing language that makes developers happy." + post_type: "article" + published: true + user_id: "01961a2a-c0de-7000-8000-000000000101" + category_id: "01961a2a-c0de-7000-8000-000000000201" + reports_count: 0 + needs_admin_review: false + +unpublished_article: + id: "01961a2a-c0de-7000-8000-000000000302" + title: "Draft Article" + content: "This is still a draft." + post_type: "article" + published: false + user_id: "01961a2a-c0de-7000-8000-000000000101" + category_id: "01961a2a-c0de-7000-8000-000000000201" + reports_count: 0 + needs_admin_review: false + +published_link: + id: "01961a2a-c0de-7000-8000-000000000303" + title: "Ruby on Rails" + url: "https://rubyonrails.org" + post_type: "link" + published: true + user_id: "01961a2a-c0de-7000-8000-000000000101" + category_id: "01961a2a-c0de-7000-8000-000000000201" + reports_count: 0 + needs_admin_review: false + +other_users_post: + id: "01961a2a-c0de-7000-8000-000000000304" + title: "Another User Post" + content: "Content by another user." + post_type: "article" + published: true + user_id: "01961a2a-c0de-7000-8000-000000000102" + category_id: "01961a2a-c0de-7000-8000-000000000201" + reports_count: 0 + needs_admin_review: false + +near_threshold_post: + id: "01961a2a-c0de-7000-8000-000000000305" + title: "Almost Hidden Post" + content: "This post has 2 reports so far." + post_type: "article" + published: true + user_id: "01961a2a-c0de-7000-8000-000000000102" + category_id: "01961a2a-c0de-7000-8000-000000000201" + reports_count: 2 + needs_admin_review: false + +at_threshold_post: + id: "01961a2a-c0de-7000-8000-000000000306" + title: "Hidden Post" + content: "This post has 3 reports." + post_type: "article" + published: true + user_id: "01961a2a-c0de-7000-8000-000000000102" + category_id: "01961a2a-c0de-7000-8000-000000000201" + reports_count: 3 + needs_admin_review: false diff --git a/test/fixtures/projects.yml b/test/fixtures/projects.yml index 456fa98..c9e7243 100644 --- a/test/fixtures/projects.yml +++ b/test/fixtures/projects.yml @@ -7,7 +7,7 @@ rails_project: github_url: "https://github.com/rubyist1/awesome-rails-gem" forks_count: 20 size: 5000 - topics: '["rails", "ruby"]' + topics: "[]" pushed_at: <%= 1.day.ago.to_fs(:db) %> hidden: false archived: false @@ -21,7 +21,7 @@ hidden_project: github_url: "https://github.com/rubyist1/secret-project" forks_count: 0 size: 1000 - topics: '["ruby"]' + topics: "[]" pushed_at: <%= 3.days.ago.to_fs(:db) %> hidden: true archived: false @@ -35,7 +35,7 @@ archived_project: github_url: "https://github.com/rubyist1/old-project" forks_count: 1 size: 500 - topics: '[]' + topics: "[]" pushed_at: <%= 30.days.ago.to_fs(:db) %> hidden: false archived: true @@ -49,7 +49,7 @@ admin_project: github_url: "https://github.com/admin_test/admin-tool" forks_count: 50 size: 10000 - topics: '["ruby", "cli"]' + topics: "[]" pushed_at: <%= 2.hours.ago.to_fs(:db) %> hidden: false archived: false diff --git a/test/fixtures/provider_credentials.yml b/test/fixtures/provider_credentials.yml new file mode 100644 index 0000000..2319cd3 --- /dev/null +++ b/test/fixtures/provider_credentials.yml @@ -0,0 +1,11 @@ +openai_key: + id: 01961a2a-c0de-7000-8000-000000000101 + provider: openai + key: api_key + value: sk-test-openai-key-1234 + +anthropic_key: + id: 01961a2a-c0de-7000-8000-000000000102 + provider: anthropic + key: api_key + value: sk-ant-test-key-5678 diff --git a/test/fixtures/settings.yml b/test/fixtures/settings.yml new file mode 100644 index 0000000..f15ff92 --- /dev/null +++ b/test/fixtures/settings.yml @@ -0,0 +1,5 @@ +one: + id: 01961a2a-c0de-7000-8000-100000000001 + stripe_secret_key: sk_test_stripe_1234 + stripe_publishable_key: pk_test_stripe_1234 + stripe_webhook_secret: whsec_test_1234 diff --git a/test/fixtures/team_languages.yml b/test/fixtures/team_languages.yml new file mode 100644 index 0000000..3066ec1 --- /dev/null +++ b/test/fixtures/team_languages.yml @@ -0,0 +1,19 @@ +# Use hardcoded UUIDv7s to maintain referential integrity across fixtures. + +team_one_english: + id: 01961a2a-c0de-7000-8000-000000000901 + team_id: 01961a2a-c0de-7000-8000-000000000501 + language_id: 01961a2a-c0de-7000-8000-000000000801 + active: true + +team_one_spanish: + id: 01961a2a-c0de-7000-8000-000000000902 + team_id: 01961a2a-c0de-7000-8000-000000000501 + language_id: 01961a2a-c0de-7000-8000-000000000802 + active: true + +team_two_english: + id: 01961a2a-c0de-7000-8000-000000000903 + team_id: 01961a2a-c0de-7000-8000-000000000502 + language_id: 01961a2a-c0de-7000-8000-000000000801 + active: true diff --git a/test/fixtures/teams.yml b/test/fixtures/teams.yml new file mode 100644 index 0000000..b2ffbb2 --- /dev/null +++ b/test/fixtures/teams.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +# +# Use hardcoded UUIDv7s to maintain referential integrity across fixtures. + +one: + id: 01961a2a-c0de-7000-8000-000000000501 + name: Team One + slug: team-one + api_key: test_api_key_team_one_12345678901234567890123456789012 + +two: + id: 01961a2a-c0de-7000-8000-000000000502 + name: Team Two + slug: team-two + api_key: test_api_key_team_two_12345678901234567890123456789012 diff --git a/test/fixtures/tool_calls.yml b/test/fixtures/tool_calls.yml new file mode 100644 index 0000000..d205c28 --- /dev/null +++ b/test/fixtures/tool_calls.yml @@ -0,0 +1,12 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +# +# Use hardcoded UUIDv7s and explicit _id columns for referential integrity. +# Note: tool_calls has circular FK with messages. + +search_call: + id: 01961a2a-c0de-7000-8000-000000000501 + message_id: 01961a2a-c0de-7000-8000-000000000402 + tool_call_id: call_abc123 + name: search + arguments: + query: "weather in Paris" diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 4ab2b80..82b0b2f 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -38,6 +38,6 @@ user_no_testimonial: github_id: "900004" role: 0 avatar_url: "https://avatars.githubusercontent.com/u/4" - name: "No Testimonial" + name: published_posts_count: 0 published_comments_count: 0 diff --git a/test/jobs/generate_testimonial_fields_job_test.rb b/test/jobs/generate_testimonial_fields_job_test.rb deleted file mode 100644 index 6b2f749..0000000 --- a/test/jobs/generate_testimonial_fields_job_test.rb +++ /dev/null @@ -1,22 +0,0 @@ -require "test_helper" - -class GenerateTestimonialFieldsJobTest < ActiveJob::TestCase - test "sets error feedback when no AI provider is configured" do - testimonial = testimonials(:unpublished) - - # In test environment, no AI credentials should be configured - # Job should gracefully handle this and set feedback - GenerateTestimonialFieldsJob.perform_now(testimonial) - - testimonial.reload - assert testimonial.ai_feedback.present? - end - - test "enqueues without error" do - testimonial = testimonials(:unpublished) - - assert_nothing_raised do - GenerateTestimonialFieldsJob.perform_later(testimonial) - end - end -end diff --git a/test/jobs/normalize_location_job_test.rb b/test/jobs/normalize_location_job_test.rb index 730f935..e284a9a 100644 --- a/test/jobs/normalize_location_job_test.rb +++ b/test/jobs/normalize_location_job_test.rb @@ -12,7 +12,7 @@ class NormalizeLocationJobTest < ActiveJob::TestCase WebMock.allow_net_connect! end - test "updates user normalized_location, coordinates, and timezone" do + test "delegates to user.geocode!" do user = users(:user_with_testimonial) user.update_columns(location: "NYC") @@ -22,24 +22,6 @@ class NormalizeLocationJobTest < ActiveJob::TestCase user.reload assert_equal "New York, US", user.normalized_location - assert_in_delta 40.7128, user.latitude, 0.001 - assert_in_delta(-74.006, user.longitude, 0.001) - assert_equal "America/New_York", user.timezone - end - - test "sets nil when geocoding fails" do - user = users(:user_with_testimonial) - user.update_columns(location: "Universe", normalized_location: "Old, US", latitude: 1.0, longitude: 1.0, timezone: "America/New_York") - - stub_photon_empty("Universe") - - NormalizeLocationJob.perform_now(user.id) - - user.reload - assert_nil user.normalized_location - assert_nil user.latitude - assert_nil user.longitude - assert_nil user.timezone end test "handles non-existent user gracefully" do @@ -50,13 +32,13 @@ class NormalizeLocationJobTest < ActiveJob::TestCase private - def stub_photon(query, city:, countrycode:, state: nil, lon: 0.0, lat: 0.0) + def stub_photon(query, city:, countrycode:, lon: 0.0, lat: 0.0) response = { type: "FeatureCollection", features: [ { type: "Feature", geometry: { type: "Point", coordinates: [ lon, lat ] }, - properties: { city: city, countrycode: countrycode, state: state } + properties: { city: city, countrycode: countrycode } } ] } @@ -64,12 +46,4 @@ def stub_photon(query, city:, countrycode:, state: nil, lon: 0.0, lat: 0.0) .with(query: { q: query, limit: "1" }) .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) end - - def stub_photon_empty(query) - response = { type: "FeatureCollection", features: [] } - - stub_request(:get, "https://photon.komoot.io/api/") - .with(query: { q: query, limit: "1" }) - .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) - end end diff --git a/test/jobs/translate_content_job_test.rb b/test/jobs/translate_content_job_test.rb new file mode 100644 index 0000000..988e82d --- /dev/null +++ b/test/jobs/translate_content_job_test.rb @@ -0,0 +1,69 @@ +require "test_helper" + +class TranslateContentJobTest < ActiveSupport::TestCase + MockResponse = Data.define(:content) + include ActiveJob::TestHelper + + setup do + @article = articles(:one) + end + + test "skips if record not found" do + assert_nothing_raised do + TranslateContentJob.new.perform("Article", "nonexistent-id", "en", "es") + end + end + + test "translates content via RubyLLM" do + mock_response = MockResponse.new(content: '{"title": "Hola Mundo", "body": "Cuerpo de prueba"}') + mock_chat = Object.new + mock_chat.define_singleton_method(:ask) { |_prompt| mock_response } + + original_chat = RubyLLM.method(:chat) + RubyLLM.define_singleton_method(:chat) { |**_| mock_chat } + + TranslateContentJob.new.perform("Article", @article.id, "en", "es") + + Mobility.with_locale(:es) do + @article.reload + assert_equal "Hola Mundo", @article.title + assert_equal "Cuerpo de prueba", @article.body + end + ensure + RubyLLM.define_singleton_method(:chat, original_chat) + end + + test "handles JSON wrapped in markdown code blocks" do + mock_response = MockResponse.new(content: "```json\n{\"title\": \"Bonjour\", \"body\": \"Corps\"}\n```") + mock_chat = Object.new + mock_chat.define_singleton_method(:ask) { |_prompt| mock_response } + + original_chat = RubyLLM.method(:chat) + RubyLLM.define_singleton_method(:chat) { |**_| mock_chat } + + TranslateContentJob.new.perform("Article", @article.id, "en", "fr") + + Mobility.with_locale(:fr) do + @article.reload + assert_equal "Bonjour", @article.title + assert_equal "Corps", @article.body + end + ensure + RubyLLM.define_singleton_method(:chat, original_chat) + end + + test "handles invalid JSON response gracefully" do + mock_response = MockResponse.new(content: "This is not JSON") + mock_chat = Object.new + mock_chat.define_singleton_method(:ask) { |_prompt| mock_response } + + original_chat = RubyLLM.method(:chat) + RubyLLM.define_singleton_method(:chat) { |**_| mock_chat } + + assert_nothing_raised do + TranslateContentJob.new.perform("Article", @article.id, "en", "es") + end + ensure + RubyLLM.define_singleton_method(:chat, original_chat) + end +end diff --git a/test/jobs/validate_testimonial_job_test.rb b/test/jobs/validate_testimonial_job_test.rb deleted file mode 100644 index 12e2291..0000000 --- a/test/jobs/validate_testimonial_job_test.rb +++ /dev/null @@ -1,22 +0,0 @@ -require "test_helper" - -class ValidateTestimonialJobTest < ActiveJob::TestCase - test "sets error feedback when no AI provider is configured" do - testimonial = testimonials(:unpublished) - testimonial.update_columns(heading: "TestHeading", subheading: "Test sub", body_text: "Test body") - - # In test environment, no AI credentials should be configured - ValidateTestimonialJob.perform_now(testimonial) - - testimonial.reload - assert testimonial.ai_feedback.present? - end - - test "enqueues without error" do - testimonial = testimonials(:unpublished) - - assert_nothing_raised do - ValidateTestimonialJob.perform_later(testimonial) - end - end -end diff --git a/test/mailers/previews/admin_mailer_preview.rb b/test/mailers/previews/admin_mailer_preview.rb new file mode 100644 index 0000000..ab52930 --- /dev/null +++ b/test/mailers/previews/admin_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/admin_mailer +class AdminMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/magic_link + def magic_link + AdminMailer.magic_link + end +end diff --git a/test/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb new file mode 100644 index 0000000..4521d89 --- /dev/null +++ b/test/mailers/previews/user_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/user_mailer +class UserMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/magic_link + def magic_link + UserMailer.magic_link + end +end diff --git a/test/middleware/malicious_path_blocker_test.rb b/test/middleware/malicious_path_blocker_test.rb index b28b9f4..67df1b5 100644 --- a/test/middleware/malicious_path_blocker_test.rb +++ b/test/middleware/malicious_path_blocker_test.rb @@ -102,6 +102,12 @@ class MaliciousPathBlockerTest < ActionDispatch::IntegrationTest assert_response :forbidden end + # ActiveStorage paths should not be blocked + test "allows ActiveStorage blob paths with file extensions" do + get "/rails/active_storage/blobs/redirect/abc123/photo.jpg" + assert_response :not_found # 404 from Rails (invalid blob), not from middleware + end + # Legitimate paths should not be blocked test "allows root path" do get "/" diff --git a/test/models/admin_test.rb b/test/models/admin_test.rb deleted file mode 100644 index 33dd194..0000000 --- a/test/models/admin_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class AdminTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/models/concerns/post/metadata_fetchable_test.rb b/test/models/concerns/post/metadata_fetchable_test.rb new file mode 100644 index 0000000..ea7c9ea --- /dev/null +++ b/test/models/concerns/post/metadata_fetchable_test.rb @@ -0,0 +1,100 @@ +require "test_helper" +require "webmock/minitest" + +class Post::MetadataFetchableTest < ActiveSupport::TestCase + setup do + @user = users(:user_with_testimonial) + @category = categories(:general) + WebMock.enable! + WebMock.disable_net_connect! + end + + teardown do + WebMock.reset! + WebMock.allow_net_connect! + end + + def build_link_post(url) + Post.new(title: "Test Link", post_type: "link", url: url, user: @user, category: @category) + end + + test "fetch_metadata! extracts og:title from HTML" do + stub_request(:get, "https://example.com/article").to_return( + status: 200, + body: <<~HTML, + + + + +

Content here

+ + HTML + headers: { "Content-Type" => "text/html" } + ) + + post = build_link_post("https://example.com/article") + metadata = post.fetch_metadata! + + assert_equal "My Great Article", metadata[:title] + end + + test "fetch_metadata! extracts og:description" do + stub_request(:get, "https://example.com/article").to_return( + status: 200, + body: <<~HTML, + + + + + + + + HTML + headers: { "Content-Type" => "text/html" } + ) + + post = build_link_post("https://example.com/article") + metadata = post.fetch_metadata! + + assert_equal "A detailed description of the article.", metadata[:description] + end + + test "fetch_metadata! returns empty hash for blank url" do + post = build_link_post("") + metadata = post.fetch_metadata! + assert_equal({}, metadata) + end + + test "fetch_metadata! handles connection errors gracefully" do + stub_request(:get, "https://example.com/broken").to_raise(Net::OpenTimeout) + + post = build_link_post("https://example.com/broken") + metadata = post.fetch_metadata! + + assert_equal({}, metadata) + end + + test "fetch_external_content extracts page text" do + stub_request(:get, "https://example.com/page").to_return( + status: 200, + body: <<~HTML, + + + + + + +
This is the main content of the page. It has enough text to be extracted properly.
+ + + HTML + headers: { "Content-Type" => "text/html" } + ) + + post = build_link_post("https://example.com/page") + content = post.fetch_external_content + + assert_not_nil content + assert_includes content, "Page Title" + end +end diff --git a/test/models/concerns/post/svg_sanitizable_test.rb b/test/models/concerns/post/svg_sanitizable_test.rb new file mode 100644 index 0000000..4dff8c3 --- /dev/null +++ b/test/models/concerns/post/svg_sanitizable_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class Post::SvgSanitizableTest < ActiveSupport::TestCase + test "removes script elements from SVG" do + svg = "" + result = Post.sanitize_svg(svg) + assert_not_includes result, "" + end + + test "removes event handler attributes" do + svg = "" + result = Post.sanitize_svg(svg) + assert_not_includes result, "onclick" + assert_not_includes result, "onload" + end + + test "removes javascript: href attributes" do + svg = "click" + result = Post.sanitize_svg(svg) + assert_not_includes result, "javascript:" + end + + test "preserves allowed SVG elements" do + svg = "" + result = Post.sanitize_svg(svg) + assert_includes result, "path" + assert_includes result, "rect" + assert_includes result, "circle" + end + + test "preserves viewBox attribute" do + svg = "" + result = Post.sanitize_svg(svg) + assert_includes result, "viewBox" + assert_includes result, "0 0 200 200" + end + + test "returns empty string for nil input" do + assert_equal "", Post.sanitize_svg(nil) + end + + test "returns empty string for blank input" do + assert_equal "", Post.sanitize_svg("") + assert_equal "", Post.sanitize_svg(" ") + end + + test "returns empty string for non-SVG input" do + result = Post.sanitize_svg("

Not SVG

") + assert_equal "", result + end +end diff --git a/test/models/concerns/subscribable_test.rb b/test/models/concerns/subscribable_test.rb new file mode 100644 index 0000000..96bcbd6 --- /dev/null +++ b/test/models/concerns/subscribable_test.rb @@ -0,0 +1,98 @@ +require "test_helper" + +class SubscribableTest < ActiveSupport::TestCase + setup do + @team = teams(:one) + end + + test "subscribed? returns true for active status" do + @team.update!(subscription_status: "active") + assert @team.subscribed? + end + + test "subscribed? returns true for trialing status" do + @team.update!(subscription_status: "trialing") + assert @team.subscribed? + end + + test "subscribed? returns false for nil status" do + @team.update!(subscription_status: nil) + assert_not @team.subscribed? + end + + test "subscribed? returns false for canceled status" do + @team.update!(subscription_status: "canceled") + assert_not @team.subscribed? + end + + test "subscription_active? delegates to subscribed?" do + @team.update!(subscription_status: "active") + assert @team.subscription_active? + end + + test "past_due? returns true for past_due status" do + @team.update!(subscription_status: "past_due") + assert @team.past_due? + end + + test "past_due? returns false for active status" do + @team.update!(subscription_status: "active") + assert_not @team.past_due? + end + + test "trialing? returns true for trialing status" do + @team.update!(subscription_status: "trialing") + assert @team.trialing? + end + + test "canceled? returns true for canceled status" do + @team.update!(subscription_status: "canceled") + assert @team.canceled? + end + + test "subscribed scope returns active and trialing teams" do + @team.update!(subscription_status: "active") + teams(:two).update!(subscription_status: "canceled") + + assert_includes Team.subscribed, @team + assert_not_includes Team.subscribed, teams(:two) + end + + test "past_due scope returns past_due teams" do + @team.update!(subscription_status: "past_due") + assert_includes Team.past_due, @team + end + + test "unsubscribed scope returns nil and canceled teams" do + @team.update!(subscription_status: nil) + teams(:two).update!(subscription_status: "canceled") + + assert_includes Team.unsubscribed, @team + assert_includes Team.unsubscribed, teams(:two) + end + + test "cancellation_pending? is true when active and cancel_at_period_end" do + @team.update!(subscription_status: "active", cancel_at_period_end: true) + assert @team.cancellation_pending? + end + + test "cancellation_pending? is false when active without cancel_at_period_end" do + @team.update!(subscription_status: "active", cancel_at_period_end: false) + assert_not @team.cancellation_pending? + end + + test "cancellation_pending? is false when canceled" do + @team.update!(subscription_status: "canceled", cancel_at_period_end: true) + assert_not @team.cancellation_pending? + end + + test "cancellation_pending? is false when trialing" do + @team.update!(subscription_status: "trialing", cancel_at_period_end: true) + assert_not @team.cancellation_pending? + end + + test "subscribed? still returns true when cancellation is pending" do + @team.update!(subscription_status: "active", cancel_at_period_end: true) + assert @team.subscribed? + end +end diff --git a/test/models/concerns/testimonial/ai_generatable_test.rb b/test/models/concerns/testimonial/ai_generatable_test.rb new file mode 100644 index 0000000..9774ee0 --- /dev/null +++ b/test/models/concerns/testimonial/ai_generatable_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class Testimonial::AiGeneratableTest < ActiveSupport::TestCase + setup do + @published_testimonial = testimonials(:published) + @unpublished_testimonial = testimonials(:unpublished) + end + + # ------------------------------------------------------------------------- + # heading_taken? logic + # ------------------------------------------------------------------------- + + test "heading_taken? returns true when heading exists on another testimonial" do + # "Joy" is the heading of the published fixture + assert @unpublished_testimonial.send(:heading_taken?, "Joy") + end + + test "heading_taken? returns false for a unique heading" do + assert_not @unpublished_testimonial.send(:heading_taken?, "UniqueHeadingThatDoesNotExist") + end + + test "heading_taken? ignores own heading" do + # The published testimonial has heading "Joy" — it should not flag itself + assert_not @published_testimonial.send(:heading_taken?, "Joy") + end +end diff --git a/test/models/concerns/translatable_test.rb b/test/models/concerns/translatable_test.rb new file mode 100644 index 0000000..e465990 --- /dev/null +++ b/test/models/concerns/translatable_test.rb @@ -0,0 +1,53 @@ +require "test_helper" + +class TranslatableTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + end + + test "translatable_attributes tracks declared attributes" do + assert_includes Article.translatable_attributes, "title" + assert_includes Article.translatable_attributes, "body" + end + + test "queue_translations enqueues jobs on create" do + assert_enqueued_with(job: TranslateContentJob) do + Article.create!(team: @team, user: @user, title: "Hello World", body: "Test body") + end + end + + test "queue_translations skips when skip_translation_callbacks is set" do + article = articles(:one) + + article.skip_translation_callbacks = true + assert_no_enqueued_jobs(only: TranslateContentJob) do + article.update!(title: "Updated") + end + end + + test "queue_translations skips when no translatable attributes changed" do + article = articles(:one) + assert_no_enqueued_jobs(only: TranslateContentJob) do + article.touch + end + end + + test "source_locale returns current I18n locale" do + article = articles(:one) + + I18n.with_locale(:es) do + assert_equal :es, article.source_locale + end + + I18n.with_locale(:ru) do + assert_equal :ru, article.source_locale + end + + I18n.with_locale(:en) do + assert_equal :en, article.source_locale + end + end +end diff --git a/test/models/concerns/user/geocodable_test.rb b/test/models/concerns/user/geocodable_test.rb new file mode 100644 index 0000000..e9d4e98 --- /dev/null +++ b/test/models/concerns/user/geocodable_test.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "test_helper" +require "webmock/minitest" + +class User::GeocodableTest < ActiveSupport::TestCase + setup do + WebMock.disable_net_connect!(allow_localhost: true) + @user = users(:user_with_testimonial) + end + + teardown do + WebMock.allow_net_connect! + end + + # --- Geocoding --- + + test "geocode! sets normalized_location, coordinates, and timezone" do + @user.update_columns(location: "NYC") + stub_photon("NYC", city: "New York", countrycode: "us", lon: -74.006, lat: 40.7128) + + @user.geocode! + @user.reload + + assert_equal "New York, US", @user.normalized_location + assert_in_delta 40.7128, @user.latitude, 0.001 + assert_in_delta(-74.006, @user.longitude, 0.001) + assert_equal "America/New_York", @user.timezone + end + + test "geocode! clears fields when geocoding fails" do + @user.update_columns(location: "Universe", normalized_location: "Old, US", latitude: 1.0, longitude: 1.0, timezone: "America/New_York") + stub_photon_empty("Universe") + + @user.geocode! + @user.reload + + assert_nil @user.normalized_location + assert_nil @user.latitude + assert_nil @user.longitude + assert_nil @user.timezone + end + + test "geocode! returns nil for blank location" do + @user.update_columns(location: nil) + + @user.geocode! + @user.reload + + assert_nil @user.normalized_location + end + + test "geocode! returns nil for pure emoji location" do + @user.update_columns(location: "\u{1F30D}") + + @user.geocode! + @user.reload + + assert_nil @user.normalized_location + end + + test "geocode! returns State, CC when no city available" do + @user.update_columns(location: "California") + stub_photon("California", city: nil, countrycode: "us", state: "California", lon: -119.417, lat: 36.778) + + @user.geocode! + @user.reload + + assert_equal "California, US", @user.normalized_location + end + + test "geocode! returns just CC when only country code available" do + @user.update_columns(location: "Germany") + stub_photon("Germany", city: nil, countrycode: "de", state: nil, lon: 10.451, lat: 51.165) + + @user.geocode! + @user.reload + + assert_equal "DE", @user.normalized_location + end + + test "geocode! clears fields when result has no country code" do + @user.update_columns(location: "Somewhere") + stub_photon("Somewhere", city: "Somewhere", countrycode: nil, state: nil, lon: 0.0, lat: 0.0) + + @user.geocode! + @user.reload + + assert_nil @user.normalized_location + end + + # --- Timezone --- + + test "geocode! resolves Berlin coordinates to Europe/Berlin" do + @user.update_columns(location: "Berlin") + stub_photon("Berlin", city: "Berlin", countrycode: "de", lon: 13.405, lat: 52.52) + + @user.geocode! + @user.reload + + assert_equal "Europe/Berlin", @user.timezone + end + + test "geocode! resolves Kyiv coordinates to canonical Europe/Kyiv" do + @user.update_columns(location: "Kyiv") + stub_photon("Kyiv", city: "Kyiv", countrycode: "ua", lon: 30.52, lat: 50.45) + + @user.geocode! + @user.reload + + assert_equal "Europe/Kyiv", @user.timezone + end + + private + + def stub_photon(query, city:, countrycode:, state: nil, lon: 0.0, lat: 0.0) + response = { + type: "FeatureCollection", + features: [ { + type: "Feature", + geometry: { type: "Point", coordinates: [ lon, lat ] }, + properties: { city: city, countrycode: countrycode, state: state } + } ] + } + + stub_request(:get, "https://photon.komoot.io/api/") + .with(query: { q: query, limit: "1" }) + .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) + end + + def stub_photon_empty(query) + response = { type: "FeatureCollection", features: [] } + + stub_request(:get, "https://photon.komoot.io/api/") + .with(query: { q: query, limit: "1" }) + .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) + end +end diff --git a/test/models/language_test.rb b/test/models/language_test.rb new file mode 100644 index 0000000..1cd7947 --- /dev/null +++ b/test/models/language_test.rb @@ -0,0 +1,86 @@ +require "test_helper" + +class LanguageTest < ActiveSupport::TestCase + test "validates presence of code" do + lang = Language.new(name: "Test", native_name: "Test") + assert_not lang.valid? + assert_includes lang.errors[:code], "can't be blank" + end + + test "validates uniqueness of code" do + existing = languages(:english) + lang = Language.new(code: existing.code, name: "Duplicate", native_name: "Dup") + assert_not lang.valid? + assert_includes lang.errors[:code], "has already been taken" + end + + test "validates presence of name" do + lang = Language.new(code: "xx", native_name: "Test") + assert_not lang.valid? + assert_includes lang.errors[:name], "can't be blank" + end + + test "validates presence of native_name" do + lang = Language.new(code: "xx", name: "Test") + assert_not lang.valid? + assert_includes lang.errors[:native_name], "can't be blank" + end + + test "english? returns true for English" do + assert languages(:english).english? + end + + test "english? returns false for non-English" do + assert_not languages(:spanish).english? + end + + test "self.english finds the English language" do + assert_equal languages(:english), Language.english + end + + test "enabled scope returns only enabled languages" do + enabled = Language.enabled + assert_includes enabled, languages(:english) + assert_includes enabled, languages(:spanish) + assert_not_includes enabled, languages(:disabled_lang) + end + + test "by_name scope orders by name" do + names = Language.by_name.pluck(:name) + assert_equal names.sort, names + end + + test "allows disabling any language" do + spanish = languages(:spanish) + spanish.update!(enabled: false) + assert_not spanish.enabled? + end + + test "enabled_codes returns codes of enabled languages" do + codes = Language.enabled_codes + assert_includes codes, "en" + assert_includes codes, "es" + assert_not_includes codes, "de" + end + + test "bust cache on save" do + Rails.cache.write("language_enabled_codes", [ "old_cached" ]) + Rails.cache.write("language_available_codes", [ "old_cached" ]) + languages(:spanish).update!(name: "Updated Spanish") + assert_nil Rails.cache.read("language_enabled_codes") + assert_nil Rails.cache.read("language_available_codes") + end + + test "available_codes returns codes matching yml files in config/locales" do + codes = Language.available_codes + assert_includes codes, "en" + assert_kind_of Array, codes + assert_equal codes.sort, codes, "available_codes should be sorted" + end + + test "cannot create language with code that has no yml file" do + lang = Language.new(code: "xx", name: "Unknown", native_name: "Unknown") + assert_not lang.valid? + assert_includes lang.errors[:code], "has no matching i18n yml file" + end +end diff --git a/test/models/message_test.rb b/test/models/message_test.rb new file mode 100644 index 0000000..89d440d --- /dev/null +++ b/test/models/message_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class MessageTest < ActiveSupport::TestCase + test "calculates cost based on token usage" do + message = Message.new( + chat: chats(:one), + role: "assistant", + content: "Test", + input_tokens: 1000, + output_tokens: 500, + model: models(:gpt4) + ) + + message.save! + + assert message.cost > 0 + end +end diff --git a/test/models/post_test.rb b/test/models/post_test.rb new file mode 100644 index 0000000..ea98125 --- /dev/null +++ b/test/models/post_test.rb @@ -0,0 +1,206 @@ +require "test_helper" + +class PostTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + setup do + @user = users(:user_with_testimonial) + @category = categories(:general) + @success_category = categories(:success_stories) + end + + # ------------------------------------------------------------------------- + # Content type validation + # ------------------------------------------------------------------------- + + test "article requires content" do + post = Post.new(title: "Test Article", post_type: "article", user: @user, category: @category) + assert_not post.valid? + assert_includes post.errors[:content], "is required for articles" + end + + test "article rejects url" do + post = Post.new( + title: "Test Article", post_type: "article", + content: "Some content", url: "https://example.com", + user: @user, category: @category + ) + assert_not post.valid? + assert_includes post.errors[:url], "must be blank for articles" + end + + test "link requires url" do + post = Post.new(title: "Test Link", post_type: "link", user: @user, category: @category) + assert_not post.valid? + assert_includes post.errors[:url], "is required for external links" + end + + test "link rejects content" do + post = Post.new( + title: "Test Link", post_type: "link", + url: "https://example.com", content: "Some content", + user: @user, category: @category + ) + assert_not post.valid? + assert_includes post.errors[:content], "must be blank for external links" + end + + test "success story requires logo_svg" do + post = Post.new( + title: "Test Story", post_type: "success_story", + content: "Story content", + user: @user, category: @success_category + ) + assert_not post.valid? + assert_includes post.errors[:logo_svg], "is required for success stories" + end + + test "success story requires content" do + post = Post.new( + title: "Test Story", post_type: "success_story", + logo_svg: "", + user: @user, category: @success_category + ) + assert_not post.valid? + assert_includes post.errors[:content], "is required for success stories" + end + + test "success story rejects url" do + post = Post.new( + title: "Test Story", post_type: "success_story", + logo_svg: "", content: "Story content", + url: "https://example.com", + user: @user, category: @success_category + ) + assert_not post.valid? + assert_includes post.errors[:url], "must be blank for success stories" + end + + # ------------------------------------------------------------------------- + # SVG sanitization + # ------------------------------------------------------------------------- + + test "sanitizes script tags from logo_svg before validation" do + post = Post.new( + title: "Test Story", post_type: "success_story", + content: "Story content", + logo_svg: "", + user: @user, category: @success_category + ) + post.valid? # triggers before_validation callback + assert_not_includes post.logo_svg, "" + end + + test "sanitizes javascript event handlers from logo_svg" do + post = Post.new( + title: "Test Story", post_type: "success_story", + content: "Story content", + logo_svg: "", + user: @user, category: @success_category + ) + post.valid? + assert_not_includes post.logo_svg, "onclick" + end + + test "preserves valid SVG content after sanitization" do + valid_svg = "" + post = Post.new( + title: "Test Story", post_type: "success_story", + content: "Story content", + logo_svg: valid_svg, + user: @user, category: @success_category + ) + post.valid? + assert_includes post.logo_svg, " "application/json" }) - end - - def stub_photon_empty(query) - response = { type: "FeatureCollection", features: [] } - - stub_request(:get, "https://photon.komoot.io/api/") - .with(query: { q: query, limit: "1" }) - .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) - end -end diff --git a/test/services/timezone_resolver_test.rb b/test/services/timezone_resolver_test.rb deleted file mode 100644 index b069213..0000000 --- a/test/services/timezone_resolver_test.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class TimezoneResolverTest < ActiveSupport::TestCase - test "resolves New York coordinates" do - result = TimezoneResolver.resolve(40.7128, -74.006) - assert_equal "America/New_York", result - end - - test "resolves Berlin coordinates" do - result = TimezoneResolver.resolve(52.52, 13.405) - assert_equal "Europe/Berlin", result - end - - test "resolves Tokyo coordinates" do - result = TimezoneResolver.resolve(35.6762, 139.6503) - assert_equal "Asia/Tokyo", result - end - - test "returns Etc/UTC for nil latitude" do - assert_equal "Etc/UTC", TimezoneResolver.resolve(nil, -74.006) - end - - test "returns Etc/UTC for nil longitude" do - assert_equal "Etc/UTC", TimezoneResolver.resolve(40.7128, nil) - end - - test "returns Etc/UTC for both nil" do - assert_equal "Etc/UTC", TimezoneResolver.resolve(nil, nil) - end - - test "returns Etc/UTC for ocean coordinates" do - result = TimezoneResolver.resolve(0.0, 0.0) - assert_equal "Etc/UTC", result - end - - test "resolves Kyiv coordinates to canonical identifier" do - result = TimezoneResolver.resolve(50.45, 30.52) - assert_equal "Europe/Kyiv", result - end - - test "normalize maps legacy Europe/Kiev to Europe/Kyiv" do - assert_equal "Europe/Kyiv", TimezoneResolver.normalize("Europe/Kiev") - end - - test "normalize passes through valid identifiers" do - assert_equal "America/New_York", TimezoneResolver.normalize("America/New_York") - end - - test "normalize returns Etc/UTC for blank" do - assert_equal "Etc/UTC", TimezoneResolver.normalize(nil) - assert_equal "Etc/UTC", TimezoneResolver.normalize("") - end - - test "normalize returns Etc/UTC for unknown identifier" do - assert_equal "Etc/UTC", TimezoneResolver.normalize("Fake/Timezone") - end -end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..aa3ec42 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,14 +2,28 @@ require_relative "../config/environment" require "rails/test_help" +# Configure OmniAuth for testing +OmniAuth.config.test_mode = true + module ActiveSupport class TestCase - # Run tests in parallel with specified workers parallelize(workers: :number_of_processors) - - # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all + end +end - # Add more helper methods to be used by all tests here... +module SignInHelper + # Sign in by directly setting the session via a test-only endpoint + def sign_in(user) + # Open a session so we can set user_id directly + post "/_test_sign_in", params: { user_id: user.id } end + + def sign_out(_user = nil) + delete destroy_user_session_path + end +end + +class ActionDispatch::IntegrationTest + include SignInHelper end diff --git a/test/tools/articles/create_article_tool_test.rb b/test/tools/articles/create_article_tool_test.rb new file mode 100644 index 0000000..95e6a2b --- /dev/null +++ b/test/tools/articles/create_article_tool_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Articles + class CreateArticleToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Articles::CreateArticleTool, title: "Test") + end + assert_match(/x-api-key/, error.message) + end + + test "creates article" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Articles::CreateArticleTool, title: "MCP Article", body: "Created via MCP") + + assert result[:success] + assert_equal "MCP Article", result[:data][:title] + assert Article.find(result[:data][:id]) + end + end +end diff --git a/test/tools/articles/delete_article_tool_test.rb b/test/tools/articles/delete_article_tool_test.rb new file mode 100644 index 0000000..86f93f2 --- /dev/null +++ b/test/tools/articles/delete_article_tool_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Articles + class DeleteArticleToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + @article = articles(:one) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Articles::DeleteArticleTool, id: @article.id) + end + assert_match(/x-api-key/, error.message) + end + + test "deletes article" do + mock_mcp_request(team: @team, user: @user) + + assert_difference "Article.count", -1 do + result = call_tool(Articles::DeleteArticleTool, id: @article.id) + assert result[:success] + end + end + + test "returns error for non-existent article" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Articles::DeleteArticleTool, id: "nonexistent") + assert_not result[:success] + end + end +end diff --git a/test/tools/articles/list_articles_tool_test.rb b/test/tools/articles/list_articles_tool_test.rb new file mode 100644 index 0000000..3340ed6 --- /dev/null +++ b/test/tools/articles/list_articles_tool_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Articles + class ListArticlesToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Articles::ListArticlesTool) + end + assert_match(/x-api-key/, error.message) + end + + test "returns team articles" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Articles::ListArticlesTool) + + assert result[:success] + assert_kind_of Array, result[:data] + ids = result[:data].map { |a| a[:id] } + assert_includes ids, articles(:one).id + end + end +end diff --git a/test/tools/articles/show_article_tool_test.rb b/test/tools/articles/show_article_tool_test.rb new file mode 100644 index 0000000..79c8f20 --- /dev/null +++ b/test/tools/articles/show_article_tool_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Articles + class ShowArticleToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + @article = articles(:one) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Articles::ShowArticleTool, id: @article.id) + end + assert_match(/x-api-key/, error.message) + end + + test "returns article details" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Articles::ShowArticleTool, id: @article.id) + + assert result[:success] + assert_equal @article.id, result[:data][:id] + assert_equal "First Article", result[:data][:title] + end + + test "returns error for non-existent article" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Articles::ShowArticleTool, id: "nonexistent") + + assert_not result[:success] + end + end +end diff --git a/test/tools/articles/update_article_tool_test.rb b/test/tools/articles/update_article_tool_test.rb new file mode 100644 index 0000000..05a9afd --- /dev/null +++ b/test/tools/articles/update_article_tool_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Articles + class UpdateArticleToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + @article = articles(:one) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Articles::UpdateArticleTool, id: @article.id, title: "Updated") + end + assert_match(/x-api-key/, error.message) + end + + test "updates article" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Articles::UpdateArticleTool, id: @article.id, title: "Updated Title") + + assert result[:success] + assert_equal "Updated Title", @article.reload.title + end + end +end diff --git a/test/tools/billing/cancel_subscription_tool_test.rb b/test/tools/billing/cancel_subscription_tool_test.rb new file mode 100644 index 0000000..96fbef7 --- /dev/null +++ b/test/tools/billing/cancel_subscription_tool_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Billing + class CancelSubscriptionToolTest < McpToolTestCase + setup do + @team = teams(:one) + @admin = users(:user_with_testimonial) + end + + test "returns error when no active subscription" do + mock_mcp_request(team: @team, user: @admin) + + result = call_tool(Billing::CancelSubscriptionTool) + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + + test "rejects non-admin member" do + team_two = teams(:two) + mock_mcp_request(team: team_two, user: @admin) + + result = call_tool(Billing::CancelSubscriptionTool) + + assert_not result[:success] + assert_equal "forbidden", result[:code] + end + + test "requires authentication" do + assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Billing::CancelSubscriptionTool) + end + end + end +end diff --git a/test/tools/billing/create_checkout_tool_test.rb b/test/tools/billing/create_checkout_tool_test.rb new file mode 100644 index 0000000..96967a3 --- /dev/null +++ b/test/tools/billing/create_checkout_tool_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Billing + class CreateCheckoutToolTest < McpToolTestCase + setup do + @team = teams(:one) + @admin = users(:user_with_testimonial) + end + + test "rejects non-admin member" do + team_two = teams(:two) + mock_mcp_request(team: team_two, user: @admin) + + result = call_tool(Billing::CreateCheckoutTool, + price_id: "price_123", + success_url: "http://example.com/success", + cancel_url: "http://example.com/cancel") + + assert_not result[:success] + assert_equal "forbidden", result[:code] + end + + test "requires authentication" do + assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Billing::CreateCheckoutTool, + price_id: "price_123", + success_url: "http://example.com/success", + cancel_url: "http://example.com/cancel") + end + end + end +end diff --git a/test/tools/billing/get_billing_portal_tool_test.rb b/test/tools/billing/get_billing_portal_tool_test.rb new file mode 100644 index 0000000..a3377b9 --- /dev/null +++ b/test/tools/billing/get_billing_portal_tool_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Billing + class GetBillingPortalToolTest < McpToolTestCase + setup do + @team = teams(:one) + @admin = users(:user_with_testimonial) + end + + test "returns error when no stripe customer" do + mock_mcp_request(team: @team, user: @admin) + + result = call_tool(Billing::GetBillingPortalTool, return_url: "http://example.com/billing") + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + + test "rejects non-admin member" do + team_two = teams(:two) + mock_mcp_request(team: team_two, user: @admin) + + result = call_tool(Billing::GetBillingPortalTool, return_url: "http://example.com/billing") + + assert_not result[:success] + assert_equal "forbidden", result[:code] + end + + test "requires authentication" do + assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Billing::GetBillingPortalTool, return_url: "http://example.com/billing") + end + end + end +end diff --git a/test/tools/billing/list_prices_tool_test.rb b/test/tools/billing/list_prices_tool_test.rb new file mode 100644 index 0000000..4e49c29 --- /dev/null +++ b/test/tools/billing/list_prices_tool_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Billing + class ListPricesToolTest < McpToolTestCase + test "returns prices without authentication" do + original_method = Price.method(:all) + Price.define_singleton_method(:all) do + [ Price.new(id: "price_1", product_name: "Pro", unit_amount: 1900, currency: "usd", interval: "month", interval_count: 1) ] + end + + result = call_tool(Billing::ListPricesTool) + + assert result[:success] + assert_equal 1, result[:data].size + assert_equal "Pro", result[:data].first[:product_name] + assert_equal "$19.00", result[:data].first[:amount] + assert_equal "per month", result[:data].first[:interval] + ensure + Price.define_singleton_method(:all, original_method) + end + end +end diff --git a/test/tools/billing/resume_subscription_tool_test.rb b/test/tools/billing/resume_subscription_tool_test.rb new file mode 100644 index 0000000..ac9b7c6 --- /dev/null +++ b/test/tools/billing/resume_subscription_tool_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Billing + class ResumeSubscriptionToolTest < McpToolTestCase + setup do + @team = teams(:one) + @admin = users(:user_with_testimonial) + end + + test "returns error when no pending cancellation" do + @team.update!(subscription_status: "active", cancel_at_period_end: false) + mock_mcp_request(team: @team, user: @admin) + + result = call_tool(Billing::ResumeSubscriptionTool) + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + + test "rejects non-admin member" do + team_two = teams(:two) + mock_mcp_request(team: team_two, user: @admin) + + result = call_tool(Billing::ResumeSubscriptionTool) + + assert_not result[:success] + assert_equal "forbidden", result[:code] + end + + test "requires authentication" do + assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Billing::ResumeSubscriptionTool) + end + end + end +end diff --git a/test/tools/billing/show_subscription_tool_test.rb b/test/tools/billing/show_subscription_tool_test.rb new file mode 100644 index 0000000..9875bef --- /dev/null +++ b/test/tools/billing/show_subscription_tool_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Billing + class ShowSubscriptionToolTest < McpToolTestCase + setup do + @team = teams(:one) + @admin = users(:user_with_testimonial) # owner of team_one + @member = users(:user_with_testimonial) # member of team_two (not admin) + end + + test "returns subscription info for admin" do + @team.update!(subscription_status: "active", current_period_ends_at: Time.utc(2026, 3, 1)) + mock_mcp_request(team: @team, user: @admin) + + result = call_tool(Billing::ShowSubscriptionTool) + + assert result[:success] + assert_equal "active", result[:data][:subscription_status] + assert result[:data][:subscribed] + end + + test "rejects non-admin member" do + team_two = teams(:two) + mock_mcp_request(team: team_two, user: @member) + + # user_one is member (not admin) of team_two + result = call_tool(Billing::ShowSubscriptionTool) + + assert_not result[:success] + assert_equal "forbidden", result[:code] + end + + test "requires authentication" do + assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Billing::ShowSubscriptionTool) + end + end + end +end diff --git a/test/tools/chats/create_chat_tool_test.rb b/test/tools/chats/create_chat_tool_test.rb new file mode 100644 index 0000000..b56ee73 --- /dev/null +++ b/test/tools/chats/create_chat_tool_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Chats + class CreateChatToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + @model = models(:gpt4) + end + + test "requires team API key" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Chats::CreateChatTool, model_id: @model.id) + end + assert_match(/x-api-key/, error.message) + end + + test "creates chat with valid model when model is enabled" do + mock_mcp_request(team: @team, user: @user) + + # Skip if no providers are configured (no API keys in test) + skip "No providers configured" if Model.configured_providers.empty? + + assert_difference "@user.chats.count", 1 do + result = call_tool(Chats::CreateChatTool, model_id: @model.id) + + assert result[:success] + assert result[:data][:id].present? + assert_equal @model.model_id, result[:data][:model_id] + end + end + + test "returns error when model is not enabled" do + mock_mcp_request(team: @team, user: @user) + + # In test environment without API keys, models are not enabled + result = call_tool(Chats::CreateChatTool, model_id: @model.id) + + # Either success (if API key configured) or error (if not) + if Model.enabled.find_by(id: @model.id) + assert result[:success] + else + assert_not result[:success] + assert_equal "invalid_model", result[:code] + end + end + + test "returns error for invalid model" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::CreateChatTool, model_id: "nonexistent") + + assert_not result[:success] + assert_equal "invalid_model", result[:code] + end + end +end diff --git a/test/tools/chats/delete_chat_tool_test.rb b/test/tools/chats/delete_chat_tool_test.rb new file mode 100644 index 0000000..0e0f73a --- /dev/null +++ b/test/tools/chats/delete_chat_tool_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Chats + class DeleteChatToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + @chat = chats(:one) + end + + test "requires team API key" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Chats::DeleteChatTool, id: @chat.id) + end + assert_match(/x-api-key/, error.message) + end + + test "deletes chat" do + mock_mcp_request(team: @team, user: @user) + + assert_difference "@user.chats.count", -1 do + result = call_tool(Chats::DeleteChatTool, id: @chat.id) + + assert result[:success] + assert_equal @chat.id, result[:data][:id] + end + end + + test "returns error for non-existent chat" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::DeleteChatTool, id: "nonexistent") + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + + test "cannot delete other user's chat" do + mock_mcp_request(team: @team, user: @user) + other_chat = chats(:two) + + result = call_tool(Chats::DeleteChatTool, id: other_chat.id) + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + end +end diff --git a/test/tools/chats/list_chats_tool_test.rb b/test/tools/chats/list_chats_tool_test.rb new file mode 100644 index 0000000..ff7093c --- /dev/null +++ b/test/tools/chats/list_chats_tool_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Chats + class ListChatsToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + @chat = chats(:one) + end + + test "requires team API key" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Chats::ListChatsTool) + end + assert_match(/x-api-key/, error.message) + end + + test "requires user email header" do + mock_mcp_request(team: @team) + + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Chats::ListChatsTool) + end + assert_match(/x-user-email/, error.message) + end + + test "returns user chats when authenticated" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::ListChatsTool) + + assert result[:success] + assert_kind_of Array, result[:data] + assert_includes result[:data].map { |c| c[:id] }, @chat.id + end + + test "respects limit parameter" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::ListChatsTool, limit: 1) + + assert result[:success] + assert_equal 1, result[:data].size + end + + test "returns chats in recent order by default" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::ListChatsTool) + + assert result[:success] + # Most recent first + timestamps = result[:data].map { |c| c[:created_at] } + assert_equal timestamps, timestamps.sort.reverse + end + end +end diff --git a/test/tools/chats/show_chat_tool_test.rb b/test/tools/chats/show_chat_tool_test.rb new file mode 100644 index 0000000..dc30753 --- /dev/null +++ b/test/tools/chats/show_chat_tool_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Chats + class ShowChatToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + @chat = chats(:one) + end + + test "requires team API key" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Chats::ShowChatTool, id: @chat.id) + end + assert_match(/x-api-key/, error.message) + end + + test "returns chat with messages when authenticated" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::ShowChatTool, id: @chat.id) + + assert result[:success] + assert_equal @chat.id, result[:data][:id] + assert_kind_of Array, result[:data][:messages] + end + + test "returns chat without messages when include_messages is false" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::ShowChatTool, id: @chat.id, include_messages: false) + + assert result[:success] + assert_equal @chat.id, result[:data][:id] + assert_nil result[:data][:messages] + end + + test "returns error for non-existent chat" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::ShowChatTool, id: "nonexistent") + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + + test "returns error for other user's chat" do + mock_mcp_request(team: @team, user: @user) + other_chat = chats(:two) + + result = call_tool(Chats::ShowChatTool, id: other_chat.id) + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + end +end diff --git a/test/tools/chats/update_chat_tool_test.rb b/test/tools/chats/update_chat_tool_test.rb new file mode 100644 index 0000000..cb9c73c --- /dev/null +++ b/test/tools/chats/update_chat_tool_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Chats + class UpdateChatToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + @chat = chats(:one) + @new_model = models(:claude) + end + + test "requires team API key" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Chats::UpdateChatTool, id: @chat.id, model_id: @new_model.id) + end + assert_match(/x-api-key/, error.message) + end + + test "updates chat model when model is enabled" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::UpdateChatTool, id: @chat.id, model_id: @new_model.id) + + if Model.enabled.find_by(id: @new_model.id) + # Provider configured — either succeeds or returns config error + if result[:success] + assert_equal @new_model.model_id, result[:data][:model_id] + @chat.reload + assert_equal @new_model.model_id, @chat.model_id + else + assert_equal "provider_not_configured", result[:code] + end + else + assert_not result[:success] + assert_equal "invalid_model", result[:code] + end + end + + test "returns error when new model is not enabled" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::UpdateChatTool, id: @chat.id, model_id: "nonexistent") + + assert_not result[:success] + assert_equal "invalid_model", result[:code] + end + + test "returns error for non-existent chat" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::UpdateChatTool, id: "nonexistent", model_id: @new_model.id) + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + + test "returns error for invalid model" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Chats::UpdateChatTool, id: @chat.id, model_id: "nonexistent") + + assert_not result[:success] + assert_equal "invalid_model", result[:code] + end + end +end diff --git a/test/tools/languages/add_team_language_tool_test.rb b/test/tools/languages/add_team_language_tool_test.rb new file mode 100644 index 0000000..0be666d --- /dev/null +++ b/test/tools/languages/add_team_language_tool_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Languages + class AddTeamLanguageToolTest < McpToolTestCase + setup do + @team = teams(:two) + @user = users(:user_without_testimonial) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Languages::AddTeamLanguageTool, language_code: "es") + end + assert_match(/x-api-key/, error.message) + end + + test "adds language to team" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Languages::AddTeamLanguageTool, language_code: "es") + + assert result[:success] + assert_includes @team.active_language_codes, "es" + end + + test "returns error for non-admin" do + mock_mcp_request(team: teams(:two), user: users(:user_with_testimonial)) + + result = call_tool(Languages::AddTeamLanguageTool, language_code: "es") + + assert_not result[:success] + assert_match(/Admin/, result[:error]) + end + + test "returns error for invalid language code" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Languages::AddTeamLanguageTool, language_code: "xx") + + assert_not result[:success] + assert_match(/not found/, result[:error]) + end + end +end diff --git a/test/tools/languages/list_languages_tool_test.rb b/test/tools/languages/list_languages_tool_test.rb new file mode 100644 index 0000000..f307a00 --- /dev/null +++ b/test/tools/languages/list_languages_tool_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Languages + class ListLanguagesToolTest < McpToolTestCase + test "returns enabled languages without auth" do + result = call_tool(Languages::ListLanguagesTool) + + assert result[:success] + codes = result[:data].map { |l| l[:code] } + assert_includes codes, "en" + assert_includes codes, "es" + assert_not_includes codes, "de" + end + end +end diff --git a/test/tools/languages/list_team_languages_tool_test.rb b/test/tools/languages/list_team_languages_tool_test.rb new file mode 100644 index 0000000..43ac6c4 --- /dev/null +++ b/test/tools/languages/list_team_languages_tool_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Languages + class ListTeamLanguagesToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Languages::ListTeamLanguagesTool) + end + assert_match(/x-api-key/, error.message) + end + + test "returns team active languages" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Languages::ListTeamLanguagesTool) + + assert result[:success] + codes = result[:data].map { |l| l[:code] } + assert_includes codes, "en" + assert_includes codes, "es" + end + end +end diff --git a/test/tools/languages/remove_team_language_tool_test.rb b/test/tools/languages/remove_team_language_tool_test.rb new file mode 100644 index 0000000..f3355e5 --- /dev/null +++ b/test/tools/languages/remove_team_language_tool_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Languages + class RemoveTeamLanguageToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + end + + test "requires authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Languages::RemoveTeamLanguageTool, language_code: "es") + end + assert_match(/x-api-key/, error.message) + end + + test "removes language from team" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Languages::RemoveTeamLanguageTool, language_code: "es") + + assert result[:success] + assert_not_includes @team.active_language_codes, "es" + end + + test "prevents removing last language" do + mock_mcp_request(team: @team, user: @user) + + # Remove Spanish first so English is the only one left + call_tool(Languages::RemoveTeamLanguageTool, language_code: "es") + + result = call_tool(Languages::RemoveTeamLanguageTool, language_code: "en") + + assert_not result[:success] + assert_match(/At least one language/, result[:error]) + end + end +end diff --git a/test/tools/mcp_test_helper.rb b/test/tools/mcp_test_helper.rb new file mode 100644 index 0000000..4799451 --- /dev/null +++ b/test/tools/mcp_test_helper.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Helper module for testing MCP tools and resources +module McpTestHelper + # Store current test team/user/admin for authentication + attr_accessor :mcp_test_team, :mcp_test_user, :mcp_test_admin + + # Set up request context for MCP tools/resources + # @param team [Team, nil] Team to authenticate with (via API key) + # @param user [User, nil] User to authenticate with (via email header) + # @param admin [Admin, nil] Admin to authenticate with + def mock_mcp_request(team: nil, user: nil, admin: nil) + @mcp_test_team = team + @mcp_test_user = user + @mcp_test_admin = admin + end + + # Clear request context after test + def clear_mcp_request + @mcp_test_team = nil + @mcp_test_user = nil + @mcp_test_admin = nil + end + + # Call a tool class with given arguments + # @param tool_class [Class] The tool class to instantiate and call + # @param args [Hash] Arguments to pass to the tool + # @return [Hash, String] The tool result + def call_tool(tool_class, **args) + tool = tool_class.new + + # Inject headers for authentication (fast-mcp uses lowercase with dashes) + headers = {} + headers["x-api-key"] = @mcp_test_team.api_key if @mcp_test_team&.api_key + headers["x-user-email"] = @mcp_test_user.email if @mcp_test_user&.email + + # Override headers method on this instance + tool.define_singleton_method(:headers) { headers } + + tool.call(**args) + end + + # Call a resource class with given parameters + # @param resource_class [Class] The resource class to instantiate + # @param params [Hash] Parameters to pass to content method + # @return [String] The resource content (JSON string) + def call_resource(resource_class, **params) + resource = resource_class.new + if params.empty? + resource.content + else + resource.content(**params) + end + end + + # Parse JSON response from resource + # @param json_string [String] JSON string to parse + # @return [Hash] Parsed JSON + def parse_resource(json_string) + JSON.parse(json_string, symbolize_names: true) + end +end + +# Base test case for MCP tool tests +class McpToolTestCase < ActiveSupport::TestCase + include McpTestHelper + + teardown do + clear_mcp_request + end +end + +# Base test case for MCP resource tests +class McpResourceTestCase < ActiveSupport::TestCase + include McpTestHelper + + teardown do + clear_mcp_request + end +end diff --git a/test/tools/messages/create_message_tool_test.rb b/test/tools/messages/create_message_tool_test.rb new file mode 100644 index 0000000..50bdb1e --- /dev/null +++ b/test/tools/messages/create_message_tool_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Messages + class CreateMessageToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + @chat = chats(:one) + end + + test "requires team API key" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Messages::CreateMessageTool, chat_id: @chat.id, content: "Hello") + end + assert_match(/x-api-key/, error.message) + end + + test "returns error for non-existent chat" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Messages::CreateMessageTool, chat_id: "nonexistent", content: "Hello") + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + + test "cannot send message to other user's chat" do + mock_mcp_request(team: @team, user: @user) + other_chat = chats(:two) + + result = call_tool(Messages::CreateMessageTool, chat_id: other_chat.id, content: "Hello") + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + + # Note: Full message creation test requires mocking the LLM API + # which is outside the scope of unit tests + end +end diff --git a/test/tools/messages/list_messages_tool_test.rb b/test/tools/messages/list_messages_tool_test.rb new file mode 100644 index 0000000..a83e609 --- /dev/null +++ b/test/tools/messages/list_messages_tool_test.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Messages + class ListMessagesToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + @chat = chats(:one) + end + + test "requires team API key" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Messages::ListMessagesTool, chat_id: @chat.id) + end + assert_match(/x-api-key/, error.message) + end + + test "returns messages for chat" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Messages::ListMessagesTool, chat_id: @chat.id) + + assert result[:success] + assert_kind_of Array, result[:data] + end + + test "respects limit parameter" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Messages::ListMessagesTool, chat_id: @chat.id, limit: 1) + + assert result[:success] + assert result[:data].size <= 1 + end + + test "returns error for non-existent chat" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Messages::ListMessagesTool, chat_id: "nonexistent") + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + + test "cannot access other user's chat messages" do + mock_mcp_request(team: @team, user: @user) + other_chat = chats(:two) + + result = call_tool(Messages::ListMessagesTool, chat_id: other_chat.id) + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + end +end diff --git a/test/tools/models/list_models_tool_test.rb b/test/tools/models/list_models_tool_test.rb new file mode 100644 index 0000000..c2367df --- /dev/null +++ b/test/tools/models/list_models_tool_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Models + class ListModelsToolTest < McpToolTestCase + test "returns models without authentication" do + result = call_tool(Models::ListModelsTool) + + assert result[:success] + assert_kind_of Array, result[:data] + end + + test "filters by provider" do + result = call_tool(Models::ListModelsTool, provider: "openai", enabled_only: false) + + assert result[:success] + result[:data].each do |model| + assert_equal "openai", model[:provider] + end + end + + test "returns all models when enabled_only is false" do + result = call_tool(Models::ListModelsTool, enabled_only: false) + + assert result[:success] + assert result[:data].size >= 2 # At least our fixtures + end + end +end diff --git a/test/tools/models/refresh_models_tool_test.rb b/test/tools/models/refresh_models_tool_test.rb new file mode 100644 index 0000000..40f46c5 --- /dev/null +++ b/test/tools/models/refresh_models_tool_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Models + class RefreshModelsToolTest < McpToolTestCase + setup do + @admin = admins(:one) + end + + test "requires admin authentication" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Models::RefreshModelsTool) + end + assert_match(/Admin authentication required/, error.message) + end + + test "requires admin even with team and user authentication" do + mock_mcp_request(team: teams(:one), user: users(:user_with_testimonial)) + + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Models::RefreshModelsTool) + end + assert_match(/Admin authentication required/, error.message) + end + + # Note: Full refresh test requires mocking the provider APIs + # which is outside the scope of unit tests + end +end diff --git a/test/tools/models/show_model_tool_test.rb b/test/tools/models/show_model_tool_test.rb new file mode 100644 index 0000000..012a3ef --- /dev/null +++ b/test/tools/models/show_model_tool_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Models + class ShowModelToolTest < McpToolTestCase + setup do + @model = models(:gpt4) + end + + test "returns model by ID" do + result = call_tool(Models::ShowModelTool, model_id: @model.id) + + assert result[:success] + assert_equal @model.id, result[:data][:id] + assert_equal @model.name, result[:data][:name] + end + + test "returns model by model_id string" do + result = call_tool(Models::ShowModelTool, model_id: "gpt-4") + + assert result[:success] + assert_equal @model.id, result[:data][:id] + end + + test "returns error for non-existent model" do + result = call_tool(Models::ShowModelTool, model_id: "nonexistent") + + assert_not result[:success] + assert_equal "not_found", result[:code] + end + end +end diff --git a/test/tools/users/show_current_user_tool_test.rb b/test/tools/users/show_current_user_tool_test.rb new file mode 100644 index 0000000..ffa7f04 --- /dev/null +++ b/test/tools/users/show_current_user_tool_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Users + class ShowCurrentUserToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + end + + test "requires team API key" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Users::ShowCurrentUserTool) + end + assert_match(/x-api-key/, error.message) + end + + test "returns current user info" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Users::ShowCurrentUserTool) + + assert result[:success] + assert_equal @user.id, result[:data][:id] + assert_equal @user.email, result[:data][:email] + assert_equal @user.name, result[:data][:name] + assert_nil result[:data][:locale] + end + end +end diff --git a/test/tools/users/update_current_user_tool_test.rb b/test/tools/users/update_current_user_tool_test.rb new file mode 100644 index 0000000..cd634a1 --- /dev/null +++ b/test/tools/users/update_current_user_tool_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "test_helper" +require "tools/mcp_test_helper" + +module Users + class UpdateCurrentUserToolTest < McpToolTestCase + setup do + @team = teams(:one) + @user = users(:user_with_testimonial) + end + + test "requires team API key" do + error = assert_raises(FastMcp::Tool::InvalidArgumentsError) do + call_tool(Users::UpdateCurrentUserTool, name: "New Name") + end + assert_match(/x-api-key/, error.message) + end + + test "updates user name" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Users::UpdateCurrentUserTool, name: "Updated Name") + + assert result[:success] + assert_equal "Updated Name", result[:data][:name] + @user.reload + assert_equal "Updated Name", @user.name + end + + test "updates user locale" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Users::UpdateCurrentUserTool, locale: "es") + + assert result[:success] + assert_equal "es", result[:data][:locale] + @user.reload + assert_equal "es", @user.locale + end + + test "clears locale with auto" do + @user.update!(locale: "es") + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Users::UpdateCurrentUserTool, locale: "auto") + + assert result[:success] + @user.reload + assert_nil @user.locale + end + + test "returns error when no updates provided" do + mock_mcp_request(team: @team, user: @user) + + result = call_tool(Users::UpdateCurrentUserTool) + + assert_not result[:success] + assert_equal "no_updates", result[:code] + end + end +end