tsykvas_rails_template 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +200 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +589 -0
- data/Rakefile +17 -0
- data/lib/generators/tsykvas_rails_template/companions/companions_generator.rb +273 -0
- data/lib/generators/tsykvas_rails_template/concept/concept_generator.rb +145 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/edit.html.slim.tt +5 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/edit.rb.tt +11 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/index.html.slim.tt +5 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/index.rb.tt +11 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/new.html.slim.tt +5 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/new.rb.tt +11 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/show.html.slim.tt +4 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/show.rb.tt +11 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/controller.rb.tt +45 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/create.rb.tt +31 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/destroy.rb.tt +13 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/edit.rb.tt +10 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/index.rb.tt +9 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/new.rb.tt +10 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/show.rb.tt +10 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/update.rb.tt +31 -0
- data/lib/generators/tsykvas_rails_template/install/bootstrap_installer.rb +225 -0
- data/lib/generators/tsykvas_rails_template/install/install_generator.rb +298 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/buddy.md +157 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/code-reviewer.md +117 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/security-reviewer.md +113 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/tech-lead.md +150 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/check.md +51 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/code-review.md +60 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/docs-create.md +102 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/pr-review.md +81 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/pushit.md +160 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/refactor.md +132 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/task-sum.md +47 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/tests.md +67 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/tsykvas-claude.md +262 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-docs.md +78 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-rules.md +102 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-tests.md +135 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/architecture.md +315 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/authentication.md +96 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/background-jobs.md +135 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/code-style.md +101 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/commands.md +34 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/companions.md +128 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/concepts-refactoring.md +194 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/database.md +135 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/deployment.md +138 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/design-system.md +322 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/documentation.md +89 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/forms.md +174 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/i18n.md +165 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/routing-and-namespaces.md +114 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/security.md +122 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/stimulus-controllers.md +166 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/testing-examples.md +180 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/testing.md +117 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/tsykvas_rails_template.md +280 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/ui-components.md +196 -0
- data/lib/generators/tsykvas_rails_template/install/templates/CLAUDE.md.tt +81 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/component/base.rb +6 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/operation/base.rb +124 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/operation/result.rb +56 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/component/index.html.slim +49 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/component/index.rb +11 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/operation/index.rb +17 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/controllers/concerns/operations_methods.rb +148 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/controllers/home_controller.rb +10 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/policies/application_policy.rb +33 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/policies/home_policy.rb +8 -0
- data/lib/tasks/tsykvas.rake +11 -0
- data/lib/tsykvas_rails_template/probe.rb +236 -0
- data/lib/tsykvas_rails_template/railtie.rb +13 -0
- data/lib/tsykvas_rails_template/version.rb +5 -0
- data/lib/tsykvas_rails_template.rb +18 -0
- metadata +183 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Recommended companions
|
|
2
|
+
|
|
3
|
+
`bin/rails g tsykvas_rails_template:companions` adds the gems used across
|
|
4
|
+
every project the gem author maintains, plus runs their `:install`
|
|
5
|
+
sub-generators and injects standard configuration. Run **after**
|
|
6
|
+
`tsykvas_rails_template:install`.
|
|
7
|
+
|
|
8
|
+
This doc is gem-shipped reference; `/tsykvas-claude` keeps the body
|
|
9
|
+
verbatim and only swaps host-specific examples.
|
|
10
|
+
|
|
11
|
+
## What you get
|
|
12
|
+
|
|
13
|
+
| Gem | Group | Why | Post-install action |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| `devise` | top-level | Authentication (`current_user`, `before_action :authenticate_user!`). The gem's `OperationsMethods` and `Base::Operation::Base#authorize!` need a `current_user` source — Devise is the universal choice across reference projects. | `rails g devise:install` (initializer + locale). **No User model is generated** — run `rails g devise User` (or your resource name) when ready. |
|
|
16
|
+
| `omniauth-rails_csrf_protection` | top-level | CSRF guard for OAuth callbacks. Required if Devise + OmniAuth are used together. | none |
|
|
17
|
+
| `simple_form` | top-level | Form builder with cleaner DSL than `form_with`. Components and operation scaffolds work fine without it, but you'll likely use it once you have non-trivial forms. | `rails g simple_form:install` (with `--bootstrap` if Probe sees Bootstrap). |
|
|
18
|
+
| `mini_magick` | top-level | Image processing for ActiveStorage variants and `image_processing` gem. | none — but **ImageMagick must be installed system-wide** (`brew install imagemagick` / `apt install imagemagick`). The generator doesn't manage OS packages. |
|
|
19
|
+
| `mission_control-jobs` | top-level (gated on `:solid_queue`) | Web UI for SolidQueue at `/jobs`. | Injects an admin-only mount into `config/routes.rb`. See "MissionControl::Jobs constraint" below. |
|
|
20
|
+
| `rspec-rails` | `:development, :test` | The gem assumes RSpec for testing; operation-spec patterns documented in `testing.md`. | `rails g rspec:install` (creates `.rspec`, `spec/spec_helper.rb`, `spec/rails_helper.rb`). |
|
|
21
|
+
| `factory_bot_rails` | `:development, :test` | Test data factories. | none |
|
|
22
|
+
| `faker` | `:development, :test` | Realistic fake data inside factories. | none |
|
|
23
|
+
| `shoulda-matchers` | `:test` | RSpec matchers for AR / Rails (`have_many`, `validate_presence_of`, etc.). The project explicitly avoids association/validation specs as a default — but matchers are still useful where appropriate. | Appends a `Shoulda::Matchers.configure` block to `spec/rails_helper.rb`. |
|
|
24
|
+
| `webmock` | `:test` | Stub external HTTP calls in tests. | Appends `WebMock.disable_net_connect!(allow_localhost: true)` to `spec/rails_helper.rb`. |
|
|
25
|
+
| `dotenv-rails` | `:development, :test` | Load env vars from `.env` files in dev/test. | Appends `.env` / `.env.*` / `!.env.example` rules to `.gitignore`. |
|
|
26
|
+
|
|
27
|
+
## Opt-out matrix
|
|
28
|
+
|
|
29
|
+
| Flag | Skips |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `--skip-auth` | `devise` + `omniauth-rails_csrf_protection` + `devise:install` |
|
|
32
|
+
| `--skip-forms` | `simple_form` + `simple_form:install` |
|
|
33
|
+
| `--skip-images` | `mini_magick` |
|
|
34
|
+
| `--skip-jobs-ui` | `mission_control-jobs` + the routes mount |
|
|
35
|
+
| `--skip-test` | `rspec-rails` + `factory_bot_rails` + `faker` + `shoulda-matchers` + `webmock` + `rspec:install` + config injections |
|
|
36
|
+
| `--skip-dev` | `dotenv-rails` + `.gitignore` append |
|
|
37
|
+
| `--skip-bundle` | `bundle install` after Gemfile edits |
|
|
38
|
+
| `--skip-post-install` | All `:install` sub-generators and config injections (Gemfile additions still happen) |
|
|
39
|
+
|
|
40
|
+
Combine flags freely: `bin/rails g tsykvas_rails_template:companions --skip-auth --skip-forms` adds only the test/dev/images/jobs-ui groups.
|
|
41
|
+
|
|
42
|
+
## Idempotency
|
|
43
|
+
|
|
44
|
+
Re-running `:companions` is safe:
|
|
45
|
+
|
|
46
|
+
- **Gemfile additions** check existing contents via regex; skip if present.
|
|
47
|
+
- **`:install` sub-generators** skip if their canonical config file exists
|
|
48
|
+
(`config/initializers/devise.rb`, `config/initializers/simple_form.rb`,
|
|
49
|
+
`spec/rails_helper.rb`).
|
|
50
|
+
- **Config injections** check for marker strings (`Shoulda::Matchers`,
|
|
51
|
+
`WebMock.disable_net_connect`, `MissionControl::Jobs::Engine`, `.env`)
|
|
52
|
+
and bail if found.
|
|
53
|
+
|
|
54
|
+
You can re-run after edits without losing your work, and CI runs the
|
|
55
|
+
generator twice in the smoke job to verify this.
|
|
56
|
+
|
|
57
|
+
## MissionControl::Jobs constraint
|
|
58
|
+
|
|
59
|
+
The mount injected into `config/routes.rb`:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
mount MissionControl::Jobs::Engine,
|
|
63
|
+
at: "/jobs",
|
|
64
|
+
constraints: ->(req) {
|
|
65
|
+
user = req.env["warden"]&.user
|
|
66
|
+
user.respond_to?(:admin?) && user.admin?
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Why a lambda constraint and not `authenticated :user`:
|
|
71
|
+
|
|
72
|
+
- The lambda runs **per request**, so a missing `User` model at boot
|
|
73
|
+
doesn't crash. You can install `:companions` before generating Devise's
|
|
74
|
+
User; `/jobs` will return 404 on every request until `User` exists with
|
|
75
|
+
an `admin?` method, which is exactly what you want (lock-by-default).
|
|
76
|
+
- Works with **any Warden-based auth** (Devise, custom Warden mounts).
|
|
77
|
+
Doesn't require Devise's `devise_for :users` to have run yet.
|
|
78
|
+
- `respond_to?(:admin?)` keeps the lambda from raising `NoMethodError`
|
|
79
|
+
if your User model doesn't define `admin?` yet — the route just 404s.
|
|
80
|
+
|
|
81
|
+
If your auth stack isn't Warden-based, swap the constraint:
|
|
82
|
+
```ruby
|
|
83
|
+
constraints: ->(req) {
|
|
84
|
+
current_user = YourAuthHelper.current_user_from(req)
|
|
85
|
+
current_user&.admin?
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Common follow-ups
|
|
90
|
+
|
|
91
|
+
After `bin/rails g tsykvas_rails_template:companions`:
|
|
92
|
+
|
|
93
|
+
1. **Devise User model.** When your domain is ready: `rails g devise User`.
|
|
94
|
+
Add `admin:boolean` if you want the `/jobs` mount to actually show its
|
|
95
|
+
UI: `rails g devise User admin:boolean`. Then `rails db:migrate`.
|
|
96
|
+
Don't forget to update the Devise routes line: `devise_for :users`.
|
|
97
|
+
2. **simple_form configuration.** If you skipped `--bootstrap` and want it
|
|
98
|
+
later, re-run `rails g simple_form:install --bootstrap` (it'll prompt
|
|
99
|
+
to overwrite the initializer; say yes).
|
|
100
|
+
3. **shoulda-matchers + RSpec.** The appended config block targets RSpec.
|
|
101
|
+
If you migrated to a different test framework after running
|
|
102
|
+
`:companions`, remove the block manually.
|
|
103
|
+
4. **WebMock allow-list.** If your tests need to hit a real internal
|
|
104
|
+
service (e.g. a containerized backend), wrap the call:
|
|
105
|
+
```ruby
|
|
106
|
+
WebMock.disable! { real_call } # or WebMock.allow_net_connect!
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Why these gems specifically
|
|
110
|
+
|
|
111
|
+
These are the gems that appear across the gem author's reference projects AND aren't part of the default `rails new` Gemfile. The gem deliberately doesn't ship gems that:
|
|
112
|
+
|
|
113
|
+
- Already come with Rails (Puma, Importmap, Turbo, Stimulus, Bootsnap,
|
|
114
|
+
SolidQueue / SolidCache / SolidCable, Brakeman, Rubocop-Rails-Omakase, …).
|
|
115
|
+
- Vary across projects (Tailwind vs custom CSS as alternatives to Bootstrap, MySQL vs
|
|
116
|
+
Postgres, Sidekiq vs SolidQueue when both are valid).
|
|
117
|
+
- Are essential to one project but not universal (Swagger generators, alternate
|
|
118
|
+
pagination libraries, search DSLs).
|
|
119
|
+
|
|
120
|
+
The `:companions` set is the **least common denominator across the gem
|
|
121
|
+
author's projects**, not "every gem you might want".
|
|
122
|
+
|
|
123
|
+
## Skipping `:companions` entirely
|
|
124
|
+
|
|
125
|
+
If your stack is incompatible (CanCanCan instead of Pundit, Tailwind
|
|
126
|
+
instead of Bootstrap with simple_form, Minitest instead of RSpec), don't
|
|
127
|
+
run `:companions`. The gem's core (`:install` + `:concept`) is stack-
|
|
128
|
+
agnostic and works fine without any of these.
|
data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/concepts-refactoring.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Concepts Pattern — Guide
|
|
2
|
+
|
|
3
|
+
## What a controller looks like
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# frozen_string_literal: true
|
|
7
|
+
|
|
8
|
+
class Admin::UsersController < Admin::BaseController
|
|
9
|
+
def index
|
|
10
|
+
endpoint Admin::User::Operation::Index, Admin::User::Component::Index
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def show
|
|
14
|
+
endpoint Admin::User::Operation::Show, Admin::User::Component::Show
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Every action is a one-liner: `endpoint OperationClass, ComponentClass`. For redirect-only actions the component can be omitted (the operation must set `self.redirect_path`). No AR queries, no `if`/`else`, no flash juggling in controllers.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## File structure
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
app/concepts/<namespace>/<feature>/
|
|
27
|
+
operation/
|
|
28
|
+
index.rb # business logic for index
|
|
29
|
+
show.rb
|
|
30
|
+
create.rb
|
|
31
|
+
update.rb
|
|
32
|
+
destroy.rb
|
|
33
|
+
component/
|
|
34
|
+
index.rb # ViewComponent class
|
|
35
|
+
index.html.slim # template (note .html.slim — required by ViewComponent 4.8+)
|
|
36
|
+
show.rb
|
|
37
|
+
show.html.slim
|
|
38
|
+
form.rb # shared form for new/edit
|
|
39
|
+
form.html.slim
|
|
40
|
+
new.rb # subclass of Form (breadcrumbs/title)
|
|
41
|
+
edit.rb
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Mirror the same paths under `spec/concepts/...`.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## How `endpoint` works
|
|
49
|
+
|
|
50
|
+
`endpoint` is defined in `app/controllers/concerns/operations_methods.rb`. It:
|
|
51
|
+
|
|
52
|
+
1. Calls `operation.call(params:, current_user:)`
|
|
53
|
+
2. Verifies authorization was invoked (or skipped) via `check_authorization_is_called(result)`
|
|
54
|
+
3. Yields to the optional block (used by `Users::RegistrationsController` for Devise sign-in glue)
|
|
55
|
+
4. Dispatches by `action_name` and response format
|
|
56
|
+
|
|
57
|
+
| `action_name` | Success behavior | Failure behavior |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `index`, `show`, `edit`, `new` | Render `component.new(**kwargs)` | Same, with flash alert |
|
|
60
|
+
| `create`, `update`, anything containing `destroy` | Redirect to `result.redirect_path` (or `<controller_name>_path`) with flash notice | Re-render component with 422 |
|
|
61
|
+
| `format.js` (for any action) | Redirect via `window.location.href = '<path>'` | Render component into `#modals` and toggle Bootstrap modal |
|
|
62
|
+
| `format.json` | `result.model.map(&:select2_search_result)` with pagination JSON | (same) |
|
|
63
|
+
|
|
64
|
+
Component kwargs come from `result.model`:
|
|
65
|
+
|
|
66
|
+
- `OpenStruct` → `component.new(**result.model.to_h)` (recommended for multi-value results)
|
|
67
|
+
- Single AR object → `component.new(<concept>: model)` where `<concept>` is the operation's top-level namespace, singularized for show/edit/new and pluralized for index/create/update/destroy
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Writing an operation
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# frozen_string_literal: true
|
|
75
|
+
|
|
76
|
+
class Feature::Operation::Action < Base::Operation::Base
|
|
77
|
+
def perform!(params:, current_user:)
|
|
78
|
+
# 1. Authorization (REQUIRED — pick one)
|
|
79
|
+
authorize! record, :action?
|
|
80
|
+
# or
|
|
81
|
+
self.model = ::OpenStruct.new(items: policy_scope(Item))
|
|
82
|
+
# or
|
|
83
|
+
skip_authorize # only when there's no AR record to authorize
|
|
84
|
+
|
|
85
|
+
# 2. Business logic / data loading
|
|
86
|
+
|
|
87
|
+
# 3. Set the result model
|
|
88
|
+
self.model = ::OpenStruct.new(key: value, ...)
|
|
89
|
+
|
|
90
|
+
# 4. (For create/update/destroy) trigger redirect
|
|
91
|
+
self.redirect_path = some_path
|
|
92
|
+
notice(I18n.t('notices.created'))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Authorization rules
|
|
98
|
+
|
|
99
|
+
- Internal AR models → `authorize! record, :action?` (or `policy_scope(...)` for collections, or `authorize_and_save!`)
|
|
100
|
+
- Anything truly auth-free → `skip_authorize` and (if iterating a collection) `skip_policy_scope`
|
|
101
|
+
- Per-domain base policies (`Admin::BasePolicy`, `Crm::BasePolicy`, `Screener::BasePolicy`) drive the redirect destination on `Pundit::NotAuthorizedError` — keep your `<Domain>Policy < <Domain>::BasePolicy` hierarchy intact.
|
|
102
|
+
|
|
103
|
+
### Triggering a redirect from an operation
|
|
104
|
+
|
|
105
|
+
Set `self.redirect_path` and `endpoint` will redirect:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
self.redirect_path = Rails.application.routes.url_helpers.crm_root_path
|
|
109
|
+
notice(I18n.t('notices.updated'))
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
For "resource not found" in a `show`-style action:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
def resource_not_found?(record)
|
|
116
|
+
return false unless record.nil?
|
|
117
|
+
|
|
118
|
+
add_error :base, I18n.t('alerts.resource_not_found')
|
|
119
|
+
invalid!
|
|
120
|
+
self.redirect_path = Rails.application.routes.url_helpers.admin_users_path
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Flash messages
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
notice(I18n.t('notices.created')) # success → result.message
|
|
129
|
+
notice(I18n.t('alerts.warning'), level: :alert) # warning → result.message_level == :alert
|
|
130
|
+
add_error :base, I18n.t('alerts.failed'); invalid! # failure → result.error_message
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### `OpenStruct` model
|
|
134
|
+
|
|
135
|
+
Pass multiple values via `::OpenStruct`. `endpoint` spreads it as kwargs:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
self.model = ::OpenStruct.new(
|
|
139
|
+
users: paginated_users,
|
|
140
|
+
request_params: params # only if the slim template needs request params
|
|
141
|
+
)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
component.new(**result.model.to_h) # Component.new(users: ..., request_params: ...)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Sub-operations
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
run_operation(OtherOp, params: params, current_user: current_user)
|
|
152
|
+
# Failures bubble up automatically. Pass manually_handle_errors: true to handle them yourself.
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Writing a component
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
# frozen_string_literal: true
|
|
161
|
+
|
|
162
|
+
class Feature::Component::Index < Base::Component::Base
|
|
163
|
+
def initialize(users:, request_params: nil)
|
|
164
|
+
@users = users
|
|
165
|
+
@request_params = request_params
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Rules:
|
|
171
|
+
|
|
172
|
+
- Always `# frozen_string_literal: true`.
|
|
173
|
+
- Pure presentation: data only via `initialize`. No AR queries.
|
|
174
|
+
- Use `I18n.t('full.key')` — never `t('.relative_key')`.
|
|
175
|
+
- Template at `app/concepts/<ns>/<feature>/component/<action>.html.slim` next to the `.rb`.
|
|
176
|
+
- For request params, prefer `@request_params` (passed through OpenStruct) over `helpers.params`.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Non-standard action names
|
|
181
|
+
|
|
182
|
+
`endpoint` triggers a redirect when `result.redirect_path` is set, regardless of action name. So custom actions like `archive_property` work without special handling — just make sure the operation sets `self.redirect_path`.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Refactor checklist
|
|
187
|
+
|
|
188
|
+
1. Read the controller — understand each action's intent.
|
|
189
|
+
2. Move logic into operations under `app/concepts/<ns>/<feature>/operation/`.
|
|
190
|
+
3. Create or verify components under `app/concepts/<ns>/<feature>/component/`.
|
|
191
|
+
4. Replace controller actions with `endpoint Op, Component`.
|
|
192
|
+
5. Update every locale file under `config/locales/` to mirror the new keys.
|
|
193
|
+
6. Add specs: operation spec (happy path + Pundit failure path), component spec for non-trivial render logic.
|
|
194
|
+
7. Validate: `bin/rails zeitwerk:check`, `bin/rubocop`, `bundle exec rspec`.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Database & Migrations
|
|
2
|
+
|
|
3
|
+
PostgreSQL 16, Rails 8.1, multi-database setup.
|
|
4
|
+
|
|
5
|
+
## Multi-database layout
|
|
6
|
+
|
|
7
|
+
Rails 8.1 ships with three solid_* gems, each with its own database:
|
|
8
|
+
|
|
9
|
+
| Database | Purpose | Schema file |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `<app_name>_<env>` (primary) | Application data — `users`, `properties`, ... | `db/schema.rb` |
|
|
12
|
+
| `cache` | `Rails.cache` backing (SolidCache) | `db/cache_schema.rb` |
|
|
13
|
+
| `queue` | Active Job backing (SolidQueue) | `db/queue_schema.rb` |
|
|
14
|
+
| `cable` | Action Cable backing (SolidCable) | `db/cable_schema.rb` |
|
|
15
|
+
|
|
16
|
+
`config/database.yml` only defines the primary; `cache`, `queue`, `cable` are configured per-env via the corresponding YAML files (`config/cache.yml`, `config/queue.yml`, `config/cable.yml`). In **development** and **test**, cache/queue/cable run in the primary database for simplicity. In **production** they're separate Postgres databases on the same instance.
|
|
17
|
+
|
|
18
|
+
Practical impact: `bin/rails db:prepare` handles them all. You generally don't write migrations against the cache/queue/cable schemas — those are managed by their respective gems.
|
|
19
|
+
|
|
20
|
+
## Migration conventions
|
|
21
|
+
|
|
22
|
+
- File name: `db/migrate/YYYYMMDDHHMMSS_describe_change.rb`
|
|
23
|
+
- Class name in PascalCase matching the file.
|
|
24
|
+
- Schema version is in `db/schema.rb` — keep it in version control.
|
|
25
|
+
- Always include `null: false` for required columns.
|
|
26
|
+
- Always add an index for foreign keys: `t.references :property, foreign_key: true, null: false` (creates the index automatically).
|
|
27
|
+
- Use `add_foreign_key` if you create the column without `t.references`.
|
|
28
|
+
- Default values: only at the DB layer for safety-critical defaults (NOT NULL with sensible default). Application-level defaults live in the model.
|
|
29
|
+
- For long-running migrations on large tables, use `disable_ddl_transaction!` and `add_index ..., algorithm: :concurrently`.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
class CreateReports < ActiveRecord::Migration[8.1]
|
|
33
|
+
def change
|
|
34
|
+
create_table :reports do |t|
|
|
35
|
+
t.references :property, null: false, foreign_key: true
|
|
36
|
+
t.string :title, null: false
|
|
37
|
+
t.text :body
|
|
38
|
+
t.integer :status, null: false, default: 0
|
|
39
|
+
t.timestamps
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
add_index :reports, [:property_id, :status]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Enums
|
|
48
|
+
|
|
49
|
+
Use **integer enums** at the DB layer, declared in the model:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
# db migration
|
|
53
|
+
t.integer :role, null: false, default: 1
|
|
54
|
+
|
|
55
|
+
# model
|
|
56
|
+
enum :role, { admin: 0, owner: 1, customer: 2 }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Rules:
|
|
60
|
+
- Always pin integer values explicitly (don't rely on positional ordering).
|
|
61
|
+
- Adding a new value: append to the end with the next integer. Never reorder or reuse values — existing rows reference them.
|
|
62
|
+
- The default integer in the migration should map to the most common value (here `1` = `:customer`).
|
|
63
|
+
|
|
64
|
+
## Current schema (snapshot)
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
users
|
|
68
|
+
id (bigint, PK)
|
|
69
|
+
email (string, NOT NULL, default '', unique index)
|
|
70
|
+
encrypted_password (string, NOT NULL, default '')
|
|
71
|
+
name (string, NOT NULL)
|
|
72
|
+
role (integer, NOT NULL, default 1) # User#role enum
|
|
73
|
+
reset_password_token (string, unique index)
|
|
74
|
+
reset_password_sent_at (datetime)
|
|
75
|
+
remember_created_at (datetime)
|
|
76
|
+
created_at, updated_at
|
|
77
|
+
|
|
78
|
+
properties
|
|
79
|
+
id (bigint, PK)
|
|
80
|
+
name (string, NOT NULL)
|
|
81
|
+
owner_id (bigint, FK → users.id, NOT NULL, indexed)
|
|
82
|
+
created_at, updated_at
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`properties.owner_id` → `users.id` is the property/owner relationship. `User#owned_properties` returns properties where this user is the owner. Substitute your own associations to fit your domain.
|
|
86
|
+
|
|
87
|
+
## Models
|
|
88
|
+
|
|
89
|
+
`app/models/` — keep them small:
|
|
90
|
+
|
|
91
|
+
- Validations + associations + enums + scopes only.
|
|
92
|
+
- No business logic — that lives in operations.
|
|
93
|
+
- No callbacks for cross-model side effects (use operations or jobs).
|
|
94
|
+
- Custom validators (e.g. `User#owner_can_have_only_one_property`) are fine when they enforce a true invariant.
|
|
95
|
+
|
|
96
|
+
`select2_search_result` is a model-level convention: any model used as a select2 source must implement it (called by `endpoint`'s `format.json` branch). Returns a hash like `{ id:, text: }`.
|
|
97
|
+
|
|
98
|
+
## Seeds & dev data
|
|
99
|
+
|
|
100
|
+
`db/seeds.rb` is currently empty. When you add seeds:
|
|
101
|
+
|
|
102
|
+
- Make them **idempotent** (`find_or_create_by!`).
|
|
103
|
+
- Use `Rails.env.development?` guards if a seed shouldn't run in production.
|
|
104
|
+
- Document the dev login (admin email + password) in `README.md` so a fresh checkout works.
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# db/seeds.rb
|
|
108
|
+
if Rails.env.development?
|
|
109
|
+
User.find_or_create_by!(email: 'admin@example.com') do |u|
|
|
110
|
+
u.name = 'Admin'
|
|
111
|
+
u.password = 'password'
|
|
112
|
+
u.role = :admin
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Common commands
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
bin/rails db:prepare # create + migrate (or load schema) — run on bootstrap
|
|
121
|
+
bin/rails db:migrate # apply pending migrations
|
|
122
|
+
bin/rails db:rollback # roll back last migration
|
|
123
|
+
bin/rails db:seed # run db/seeds.rb
|
|
124
|
+
bin/rails db:reset # drop + create + load schema + seed (development only)
|
|
125
|
+
bin/rails db:schema:load # load schema.rb (faster than running all migrations)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Anti-patterns
|
|
129
|
+
|
|
130
|
+
- ❌ `belongs_to :foo` without `foreign_key: true` constraint at the DB level.
|
|
131
|
+
- ❌ String-typed enums — use integer enums with explicit values.
|
|
132
|
+
- ❌ Renaming an enum value without a data migration.
|
|
133
|
+
- ❌ Reusing an integer for a different enum value (silently corrupts old rows).
|
|
134
|
+
- ❌ Long-running `add_column` on large tables without `algorithm: :concurrently` for the matching index.
|
|
135
|
+
- ❌ Business logic in `before_save` — the operation layer is the right place.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Deployment (Kamal)
|
|
2
|
+
|
|
3
|
+
The app deploys via **Kamal** (`config/deploy.yml`). Single web server runs Puma + SolidQueue + the Thruster proxy in one container.
|
|
4
|
+
|
|
5
|
+
## Where things live
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
config/deploy.yml # Service config — servers, registry, env, volumes, builder
|
|
9
|
+
.kamal/secrets # Pulls secrets from local env / 1Password (NEVER raw creds)
|
|
10
|
+
.kamal/hooks/ # Lifecycle hooks (pre-build, post-deploy, ...) — all .sample by default
|
|
11
|
+
Dockerfile # Production image
|
|
12
|
+
config/master.key # NEVER committed — used to decrypt config/credentials.yml.enc
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Service overview
|
|
16
|
+
|
|
17
|
+
From `config/deploy.yml`:
|
|
18
|
+
|
|
19
|
+
- **Service name**: `<your-app>` (set in `config/deploy.yml` — match the host app's name)
|
|
20
|
+
- **Image**: `your-user/<your-app>` (replace `your-user` with your container registry user before first deploy)
|
|
21
|
+
- **Servers**: web servers under `servers.web`
|
|
22
|
+
- **SSL**: Let's Encrypt via Kamal proxy (`proxy.ssl: true`)
|
|
23
|
+
- **Volumes**: `app_storage:/rails/storage` for Active Storage (rename to match your service)
|
|
24
|
+
- **Builder arch**: `amd64`
|
|
25
|
+
- **SolidQueue runs in Puma**: `SOLID_QUEUE_IN_PUMA=true` (single-server setup). Split it onto `servers.job` once you scale.
|
|
26
|
+
|
|
27
|
+
## Secrets
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
.kamal/secrets # Resolves at deploy time, never holds raw secrets
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Current setup pulls:
|
|
34
|
+
- `KAMAL_REGISTRY_PASSWORD` from local env
|
|
35
|
+
- `RAILS_MASTER_KEY` from `config/master.key`
|
|
36
|
+
|
|
37
|
+
To add a new secret:
|
|
38
|
+
|
|
39
|
+
1. Add the variable name to `.kamal/secrets` and resolve it (env, 1Password, file).
|
|
40
|
+
2. Reference it under `env.secret:` in `config/deploy.yml`.
|
|
41
|
+
3. The container will receive it as an env var at runtime.
|
|
42
|
+
|
|
43
|
+
**Never** put raw credentials in `config/deploy.yml` or `.kamal/secrets` — both are committed.
|
|
44
|
+
|
|
45
|
+
## Common commands
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bin/kamal setup # First-time setup (one-shot per server)
|
|
49
|
+
bin/kamal deploy # Build + push image + reload containers
|
|
50
|
+
bin/kamal redeploy # Redeploy without rebuilding
|
|
51
|
+
bin/kamal rollback # Rollback to the previous version
|
|
52
|
+
bin/kamal logs -f # Tail logs (alias defined in deploy.yml)
|
|
53
|
+
bin/kamal console # Open a Rails console on the server
|
|
54
|
+
bin/kamal shell # Bash shell on the server
|
|
55
|
+
bin/kamal dbc # Open a dbconsole
|
|
56
|
+
|
|
57
|
+
bin/kamal app exec ... # Run an ad-hoc command in the app container
|
|
58
|
+
bin/kamal proxy ... # Manage the Kamal proxy container
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`config/deploy.yml.aliases` defines `console`, `shell`, `logs`, `dbc` as shortcuts.
|
|
62
|
+
|
|
63
|
+
## Build & release flow
|
|
64
|
+
|
|
65
|
+
1. `bin/kamal deploy` builds the image locally (or via remote builder if configured).
|
|
66
|
+
2. Pushes to the registry.
|
|
67
|
+
3. Triggers `pre-deploy` hooks (none by default — `.sample` files).
|
|
68
|
+
4. Runs `db:prepare` inside the app container (handled by Rails on boot).
|
|
69
|
+
5. Replaces running containers.
|
|
70
|
+
6. Triggers `post-deploy` hooks.
|
|
71
|
+
|
|
72
|
+
Bridging in-flight requests across asset versions: `asset_path: /rails/public/assets` (Kamal copies the new and old asset directories so neither generation 404s during the swap).
|
|
73
|
+
|
|
74
|
+
## Lifecycle hooks
|
|
75
|
+
|
|
76
|
+
`.kamal/hooks/` ships with samples:
|
|
77
|
+
|
|
78
|
+
- `pre-build` — before the image is built (e.g. compile assets externally)
|
|
79
|
+
- `pre-connect` — before Kamal connects to servers
|
|
80
|
+
- `pre-deploy`, `post-deploy` — around the deploy
|
|
81
|
+
- `pre-app-boot`, `post-app-boot` — around the app container start
|
|
82
|
+
- `pre-proxy-reboot`, `post-proxy-reboot` — around the Kamal proxy
|
|
83
|
+
|
|
84
|
+
Drop the `.sample` extension to enable a hook. They are shell scripts run from the deploy machine.
|
|
85
|
+
|
|
86
|
+
## Rollback strategy
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
bin/kamal rollback # Reverts to previous image version
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Rollbacks are fast (just swap container images), but **migrations are not auto-reverted** — if you rolled forward a destructive migration, you need to manually fix the schema before rolling back.
|
|
93
|
+
|
|
94
|
+
For destructive migrations, prefer the two-step pattern:
|
|
95
|
+
1. Deploy a release that *adds* the new column / table without removing the old one.
|
|
96
|
+
2. Backfill data + switch reads/writes.
|
|
97
|
+
3. Deploy a follow-up release that drops the old column.
|
|
98
|
+
|
|
99
|
+
## CI
|
|
100
|
+
|
|
101
|
+
GitHub Actions (`.github/workflows/ci.yml`) runs on every PR:
|
|
102
|
+
|
|
103
|
+
- `bin/brakeman --no-pager`
|
|
104
|
+
- `bin/importmap audit`
|
|
105
|
+
- `bin/rubocop`
|
|
106
|
+
- `bundle exec rspec` (against PostgreSQL 16)
|
|
107
|
+
|
|
108
|
+
All four must pass before merge. CI does NOT auto-deploy — production deploys are manual via `bin/kamal deploy`.
|
|
109
|
+
|
|
110
|
+
## Environment variables
|
|
111
|
+
|
|
112
|
+
Set in `config/deploy.yml` under `env.clear` (visible) or `env.secret` (resolved from `.kamal/secrets`):
|
|
113
|
+
|
|
114
|
+
- `RAILS_MASTER_KEY` (secret) — decrypts credentials
|
|
115
|
+
- `SOLID_QUEUE_IN_PUMA=true` — runs queue inline with web
|
|
116
|
+
- `JOB_CONCURRENCY` — number of SolidQueue processes (default 1)
|
|
117
|
+
- `WEB_CONCURRENCY` — number of Puma workers (default 1)
|
|
118
|
+
- `DB_HOST` — when using an external Postgres
|
|
119
|
+
- `RAILS_LOG_LEVEL` — defaults to `info`
|
|
120
|
+
|
|
121
|
+
In development, use `.env` (loaded by `dotenv-rails`).
|
|
122
|
+
|
|
123
|
+
## Adding a new server
|
|
124
|
+
|
|
125
|
+
1. Add the IP under `servers.web` in `config/deploy.yml`.
|
|
126
|
+
2. Run `bin/kamal setup` against that server (one-time).
|
|
127
|
+
3. Run `bin/kamal deploy`.
|
|
128
|
+
|
|
129
|
+
Once you have multiple web servers, **move SolidQueue to a dedicated `servers.job` host** (set `SOLID_QUEUE_IN_PUMA=false` and use `cmd: bin/jobs`). Otherwise jobs run on every web box and double-execute.
|
|
130
|
+
|
|
131
|
+
## Anti-patterns
|
|
132
|
+
|
|
133
|
+
- ❌ Committing `config/master.key`.
|
|
134
|
+
- ❌ Hardcoding secrets in `config/deploy.yml` or `.kamal/secrets`.
|
|
135
|
+
- ❌ `kamal redeploy` after a schema change without re-running migrations (use `deploy`).
|
|
136
|
+
- ❌ Running `bin/kamal deploy` from a branch with uncommitted changes.
|
|
137
|
+
- ❌ Force-pushing to `main` (CI gates production-bound code).
|
|
138
|
+
- ❌ Multi-host deploys with `SOLID_QUEUE_IN_PUMA=true` (jobs double-execute).
|