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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +200 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +589 -0
  6. data/Rakefile +17 -0
  7. data/lib/generators/tsykvas_rails_template/companions/companions_generator.rb +273 -0
  8. data/lib/generators/tsykvas_rails_template/concept/concept_generator.rb +145 -0
  9. data/lib/generators/tsykvas_rails_template/concept/templates/component/edit.html.slim.tt +5 -0
  10. data/lib/generators/tsykvas_rails_template/concept/templates/component/edit.rb.tt +11 -0
  11. data/lib/generators/tsykvas_rails_template/concept/templates/component/index.html.slim.tt +5 -0
  12. data/lib/generators/tsykvas_rails_template/concept/templates/component/index.rb.tt +11 -0
  13. data/lib/generators/tsykvas_rails_template/concept/templates/component/new.html.slim.tt +5 -0
  14. data/lib/generators/tsykvas_rails_template/concept/templates/component/new.rb.tt +11 -0
  15. data/lib/generators/tsykvas_rails_template/concept/templates/component/show.html.slim.tt +4 -0
  16. data/lib/generators/tsykvas_rails_template/concept/templates/component/show.rb.tt +11 -0
  17. data/lib/generators/tsykvas_rails_template/concept/templates/controller.rb.tt +45 -0
  18. data/lib/generators/tsykvas_rails_template/concept/templates/operation/create.rb.tt +31 -0
  19. data/lib/generators/tsykvas_rails_template/concept/templates/operation/destroy.rb.tt +13 -0
  20. data/lib/generators/tsykvas_rails_template/concept/templates/operation/edit.rb.tt +10 -0
  21. data/lib/generators/tsykvas_rails_template/concept/templates/operation/index.rb.tt +9 -0
  22. data/lib/generators/tsykvas_rails_template/concept/templates/operation/new.rb.tt +10 -0
  23. data/lib/generators/tsykvas_rails_template/concept/templates/operation/show.rb.tt +10 -0
  24. data/lib/generators/tsykvas_rails_template/concept/templates/operation/update.rb.tt +31 -0
  25. data/lib/generators/tsykvas_rails_template/install/bootstrap_installer.rb +225 -0
  26. data/lib/generators/tsykvas_rails_template/install/install_generator.rb +298 -0
  27. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/buddy.md +157 -0
  28. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/code-reviewer.md +117 -0
  29. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/security-reviewer.md +113 -0
  30. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/tech-lead.md +150 -0
  31. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/check.md +51 -0
  32. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/code-review.md +60 -0
  33. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/docs-create.md +102 -0
  34. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/pr-review.md +81 -0
  35. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/pushit.md +160 -0
  36. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/refactor.md +132 -0
  37. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/task-sum.md +47 -0
  38. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/tests.md +67 -0
  39. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/tsykvas-claude.md +262 -0
  40. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-docs.md +78 -0
  41. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-rules.md +102 -0
  42. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-tests.md +135 -0
  43. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/architecture.md +315 -0
  44. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/authentication.md +96 -0
  45. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/background-jobs.md +135 -0
  46. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/code-style.md +101 -0
  47. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/commands.md +34 -0
  48. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/companions.md +128 -0
  49. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/concepts-refactoring.md +194 -0
  50. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/database.md +135 -0
  51. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/deployment.md +138 -0
  52. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/design-system.md +322 -0
  53. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/documentation.md +89 -0
  54. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/forms.md +174 -0
  55. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/i18n.md +165 -0
  56. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/routing-and-namespaces.md +114 -0
  57. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/security.md +122 -0
  58. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/stimulus-controllers.md +166 -0
  59. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/testing-examples.md +180 -0
  60. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/testing.md +117 -0
  61. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/tsykvas_rails_template.md +280 -0
  62. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/ui-components.md +196 -0
  63. data/lib/generators/tsykvas_rails_template/install/templates/CLAUDE.md.tt +81 -0
  64. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/component/base.rb +6 -0
  65. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/operation/base.rb +124 -0
  66. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/operation/result.rb +56 -0
  67. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/component/index.html.slim +49 -0
  68. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/component/index.rb +11 -0
  69. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/operation/index.rb +17 -0
  70. data/lib/generators/tsykvas_rails_template/install/templates/app/controllers/concerns/operations_methods.rb +148 -0
  71. data/lib/generators/tsykvas_rails_template/install/templates/app/controllers/home_controller.rb +10 -0
  72. data/lib/generators/tsykvas_rails_template/install/templates/app/policies/application_policy.rb +33 -0
  73. data/lib/generators/tsykvas_rails_template/install/templates/app/policies/home_policy.rb +8 -0
  74. data/lib/tasks/tsykvas.rake +11 -0
  75. data/lib/tsykvas_rails_template/probe.rb +236 -0
  76. data/lib/tsykvas_rails_template/railtie.rb +13 -0
  77. data/lib/tsykvas_rails_template/version.rb +5 -0
  78. data/lib/tsykvas_rails_template.rb +18 -0
  79. metadata +183 -0
@@ -0,0 +1,165 @@
1
+ # I18n
2
+
3
+ Default locale and fallbacks are configured in `config/application.rb`. Time zone is configured there too. All UI strings must come from locale files — no inline literals in components or operations.
4
+
5
+ ## Locale files
6
+
7
+ ```
8
+ config/locales/
9
+ en.yml # Application strings (one file per locale you ship)
10
+ devise.en.yml # Devise built-in strings (sign-up, errors, mailer, ...) — one per locale
11
+ simple_form.en.yml # simple_form built-in strings (required mark, error notification, ...) — one per locale
12
+ ```
13
+
14
+ **Rule:** every key must exist in every locale file. They are mirrors. CI does not enforce this — be diligent.
15
+
16
+ When you add a Devise- or simple_form-specific override, put it in the matching `<gem>.<locale>.yml`. Don't mix them with app strings in your main locale files.
17
+
18
+ ## The full-key rule
19
+
20
+ Always reference the full I18n key — never the relative `t('.foo')` shorthand:
21
+
22
+ ```ruby
23
+ # ✅ good
24
+ I18n.t('admin.users.index.title')
25
+ I18n.t('crm.property.edit.submit')
26
+
27
+ # ❌ bad
28
+ t('.title') # only resolves with conventional view paths; ViewComponents don't follow them
29
+ t('admin.users.title') # under-qualified — collisions across domains
30
+ ```
31
+
32
+ This applies in operations, components, slim templates, controllers — everywhere. The reason: ViewComponent's lookup path differs from regular Rails views, so `t('.relative_key')` is unreliable.
33
+
34
+ ## Key structure
35
+
36
+ Top-level: domain (`admin`, `crm`, `public`, `forms`, `notices`, `alerts`, `authorization`, `simple_form`).
37
+ Below that: feature → action → label.
38
+
39
+ ```yaml
40
+ en:
41
+ admin:
42
+ users:
43
+ index:
44
+ title: "Users"
45
+ table:
46
+ id: "ID"
47
+ email: "Email"
48
+ role: "Role"
49
+ delete:
50
+ confirm: "Are you sure?"
51
+
52
+ crm:
53
+ property:
54
+ edit:
55
+ title: "Edit property"
56
+ name: "Name"
57
+ name_placeholder: "Enter a name..."
58
+ submit: "Save"
59
+
60
+ notices:
61
+ created: "Created"
62
+ updated: "Updated"
63
+ deleted: "Deleted"
64
+
65
+ alerts:
66
+ failed: "Failed"
67
+ resource_not_found: "Record not found"
68
+
69
+ authorization:
70
+ admin_access_denied: "Access denied"
71
+ crm_access_denied: "CRM access denied"
72
+ action_access_denied: "Action not allowed"
73
+ ```
74
+
75
+ ## simple_form conventions
76
+
77
+ `simple_form` reads labels, placeholders, and hints by convention:
78
+
79
+ ```yaml
80
+ en:
81
+ simple_form:
82
+ labels:
83
+ property:
84
+ name: "Name"
85
+ placeholders:
86
+ property:
87
+ name: "Enter a name..."
88
+ hints:
89
+ property:
90
+ name: "Public name."
91
+ ```
92
+
93
+ With these in place, `f.input :name` auto-populates label + placeholder + hint. **Prefer this over inline `f.input :name, label: ..., placeholder: ...`** — see `forms.md`.
94
+
95
+ ## Pluralization
96
+
97
+ ```yaml
98
+ en:
99
+ reports:
100
+ count:
101
+ one: "%{count} report"
102
+ other: "%{count} reports"
103
+ ```
104
+
105
+ Use `I18n.t('reports.count', count: n)`. For locales with multiple plural forms (e.g. `one`/`few`/`many`/`other` in Slavic languages), include each form your target locales require — see the [Rails I18n plural rules](https://guides.rubyonrails.org/i18n.html#pluralization).
106
+
107
+ ## Time zone & date formatting
108
+
109
+ - App-wide time zone: set in `config/application.rb`.
110
+ - Always use `Time.zone.parse(...)`, `Time.zone.now`, `Date.current` — never `DateTime.parse` or `Time.now`.
111
+ - For display, prefer `Base::Component::Table::Table#format_date`.
112
+
113
+ For custom date formats, register them in the locale file:
114
+
115
+ ```yaml
116
+ en:
117
+ date:
118
+ formats:
119
+ default: "%Y-%m-%d"
120
+ long: "%B %-d, %Y"
121
+ ```
122
+
123
+ Then `I18n.l(date, format: :long)`.
124
+
125
+ ## Authorization-denied messages
126
+
127
+ `ApplicationController#user_not_authorized` reads three keys:
128
+
129
+ - `authorization.admin_access_denied`
130
+ - `authorization.crm_access_denied`
131
+ - `authorization.action_access_denied`
132
+
133
+ Every locale you ship must define all three.
134
+
135
+ ## UI / design-system strings
136
+
137
+ Brand- or design-system strings (sidebar section titles, taglines, navigational labels) belong in their own top-level namespace so the design layer stays decoupled from feature copy:
138
+
139
+ ```yaml
140
+ ui:
141
+ sections:
142
+ workspace: "..."
143
+ account: "..."
144
+ administration: "..."
145
+ ```
146
+
147
+ Reference via `I18n.t('ui.sections.workspace')`. Typographic constants that should never be translated (e.g. brand year markers) belong here too — keep them identical across all locales.
148
+
149
+ See [`design-system.md`](design-system.md) for the visual context behind these strings.
150
+
151
+ ## Adding a new translation
152
+
153
+ 1. Add the key to your default-locale YAML under the right namespace.
154
+ 2. Mirror it in every other locale you ship.
155
+ 3. Reference it via `I18n.t('full.key')`.
156
+ 4. If it's a `simple_form` label/placeholder/hint, follow the simple_form convention so it's picked up automatically.
157
+
158
+ ## Anti-patterns
159
+
160
+ - ❌ `t('.title')` — relative keys break in ViewComponent.
161
+ - ❌ Inline strings in components or slim templates.
162
+ - ❌ Adding a key only to one locale — fallback must work.
163
+ - ❌ Using `DateTime.parse` / `Time.now` instead of `Time.zone.*`.
164
+ - ❌ Mixing Devise/simple_form strings into your app locale files.
165
+ - ❌ Deep nesting beyond 4 levels — split into a sibling top-level key instead.
@@ -0,0 +1,114 @@
1
+ # Routing & Namespaces
2
+
3
+ This app is split into three top-level domains. Each has its own URL namespace, layout, base controller, and base policy. Read this before adding a new controller — placement is not optional.
4
+
5
+ ## Domain → namespace map
6
+
7
+ | Domain | URL prefix | Layout | Base controller | Base policy | Concepts root |
8
+ |---|---|---|---|---|---|
9
+ | **Admin** (administrators) | `/admin/...` | `app/views/layouts/admin.slim` | `Admin::BaseController` | `Admin::BasePolicy` (`user.admin?`) | `app/concepts/admin/` |
10
+ | **CRM** (business users) | `/crm/...` | `app/views/layouts/crm.slim` | `Crm::BaseController` | `Crm::BasePolicy` (admin / owner / employee / manager) | `app/concepts/crm/` |
11
+ | **Screener** (end consumers) | `/` (root) and `/screener/...` | `app/views/layouts/screener.slim` | `Screener::BaseController` | `Screener::BasePolicy` | `app/concepts/screener/` (not yet created — first feature in this domain creates it) |
12
+
13
+ Each `BaseController` runs a `before_action` that instantiates its `BasePolicy` with `current_user, nil` and raises `Pundit::NotAuthorizedError` if `policy.index?` is false. `ApplicationController#user_not_authorized` then dispatches the redirect:
14
+
15
+ | Policy class on the exception | Where the user is sent |
16
+ |---|---|
17
+ | `Admin::BasePolicy` | `crm_root_path` if owner/employee/manager, else `root_path` |
18
+ | `Crm::BasePolicy` | `crm_root_path` for crm-eligible users, else `root_path` |
19
+ | `Screener::BasePolicy` | `crm_root_path` |
20
+ | anything else | `redirect_back(fallback_location: root_path)` |
21
+
22
+ The redirect hinges on the **policy class name**, so keep `<Domain>Policy < <Domain>::BasePolicy` intact when you add new policies.
23
+
24
+ ## Adding a controller
25
+
26
+ 1. Inherit from the matching base controller — never from `ApplicationController` directly.
27
+ 2. Mount under the matching `namespace` block in `config/routes.rb`.
28
+ 3. Place operations + components under `app/concepts/<namespace>/<feature>/`.
29
+ 4. Each action is a one-liner: `endpoint Op, Component` (see `concepts-refactoring.md`).
30
+
31
+ ```ruby
32
+ # config/routes.rb
33
+ namespace :crm do
34
+ resources :reports, only: [:index, :show]
35
+ end
36
+
37
+ # app/controllers/crm/reports_controller.rb
38
+ class Crm::ReportsController < Crm::BaseController
39
+ def index
40
+ endpoint Crm::Report::Operation::Index, Crm::Report::Component::Index
41
+ end
42
+ end
43
+ ```
44
+
45
+ ## Custom (non-`resources`) routes
46
+
47
+ Some flows don't map cleanly to REST — for example `Crm::PropertyController#edit` uses a singular path with no `id` (an owner edits *their* property):
48
+
49
+ ```ruby
50
+ namespace :crm do
51
+ get "property/edit", to: "property#edit", as: :edit_property
52
+ end
53
+ ```
54
+
55
+ Use a custom path when:
56
+ - the resource is implicitly the current user's (no id needed), OR
57
+ - the action name doesn't fit `index/show/new/create/edit/update/destroy` (e.g. `archive_property`, `confirm`, `archive`).
58
+
59
+ `endpoint` handles non-standard action names automatically: if the operation sets `self.redirect_path`, it redirects; otherwise it renders the component.
60
+
61
+ ## How `endpoint` derives the redirect target
62
+
63
+ After a successful `create` / `update` / `destroy` (or any action name containing `destroy`), `endpoint` redirects to:
64
+
65
+ ```ruby
66
+ result.redirect_path || public_send("#{controller_name}_path")
67
+ ```
68
+
69
+ Two consequences:
70
+
71
+ 1. **Set `self.redirect_path` explicitly** for non-trivial cases. The fallback only works if a route helper exists with the exact name `<controller_name>_path`.
72
+ 2. **Controller name matters.** `Crm::propertiesController` falls back to `properties_path`, NOT `crm_properties_path`. If your controller's pluralized name doesn't match a route helper, you must set `self.redirect_path`.
73
+
74
+ ## Named route helpers (cheat-sheet)
75
+
76
+ - `root_path` → `Screener::HomeController#index` (public landing)
77
+ - `screener_root_path` → same controller, namespaced URL
78
+ - `admin_root_path` → `Admin::DashboardController#index`
79
+ - `crm_root_path` → `Crm::DashboardController#index`
80
+ - `crm_edit_property_path` → `Crm::PropertyController#edit`
81
+ - `new_user_session_path` / `destroy_user_session_path` — Devise sign-in/out
82
+ - `new_user_registration_path` / `user_registration_path` — Devise sign-up (uses `Users::RegistrationsController`)
83
+ - `rails_health_check_path` → `/up` (uptime probes)
84
+
85
+ When linking from an operation, prefer route helpers via `Rails.application.routes.url_helpers`:
86
+
87
+ ```ruby
88
+ self.redirect_path = Rails.application.routes.url_helpers.crm_root_path
89
+ ```
90
+
91
+ ## Devise routes
92
+
93
+ ```ruby
94
+ devise_for :users, controllers: { registrations: "users/registrations" }
95
+ ```
96
+
97
+ Only `registrations` is overridden — see `authentication.md` for the custom sign-up flow. Other Devise controllers (sessions, passwords) use defaults.
98
+
99
+ ## What lives where
100
+
101
+ ```
102
+ config/routes.rb # All routes — keep namespaced
103
+ app/controllers/
104
+ application_controller.rb # Pundit + OperationsMethods + redirect dispatch
105
+ admin/base_controller.rb # layout + Admin::BasePolicy gate
106
+ crm/base_controller.rb # layout + Crm::BasePolicy gate
107
+ screener/base_controller.rb # layout + Screener::BasePolicy gate
108
+ users/registrations_controller.rb # custom Devise sign-up
109
+ app/policies/
110
+ application_policy.rb # Pundit baseline
111
+ admin/base_policy.rb
112
+ crm/base_policy.rb
113
+ screener/base_policy.rb
114
+ ```
@@ -0,0 +1,122 @@
1
+ # Security
2
+
3
+ Defaults Rails 8.1 ships with, plus a few project-specific settings. Run `bin/brakeman --no-pager` before opening a PR — it's part of CI.
4
+
5
+ ## Authorization
6
+
7
+ **Pundit** is mandatory in every operation. `OperationsMethods#check_authorization_is_called` enforces this — any operation that doesn't call one of `authorize!` / `policy_scope` / `skip_authorize` / `authorize_and_save!` raises `Pundit::AuthorizationNotPerformedError` after the operation runs.
8
+
9
+ See `architecture.md` for the full Pundit setup. Per-domain base policies (`Admin::BasePolicy`, `Crm::BasePolicy`, `Screener::BasePolicy`) gate entire namespaces via the `BaseController#before_action`.
10
+
11
+ ## Parameter filtering
12
+
13
+ `config/initializers/filter_parameter_logging.rb`:
14
+
15
+ ```ruby
16
+ Rails.application.config.filter_parameters += [
17
+ :passw, :email, :secret, :token, :_key, :crypt, :salt,
18
+ :certificate, :otp, :ssn, :cvv, :cvc
19
+ ]
20
+ ```
21
+
22
+ `:passw` is a partial match (covers `password`, `password_confirmation`, ...). `:email` is filtered out of logs to satisfy data-protection norms — be aware that even server logs won't show emails.
23
+
24
+ When you add a new sensitive attribute, add the matching token here.
25
+
26
+ ## Content Security Policy
27
+
28
+ `config/initializers/content_security_policy.rb` is currently **commented out** (Rails default). When you enable it for production:
29
+
30
+ ```ruby
31
+ Rails.application.configure do
32
+ config.content_security_policy do |policy|
33
+ policy.default_src :self, :https
34
+ policy.script_src :self, :https, :unsafe_inline # only if needed for importmap nonces
35
+ policy.style_src :self, :https
36
+ policy.img_src :self, :https, :data
37
+ policy.font_src :self, :https, :data
38
+ policy.object_src :none
39
+ end
40
+
41
+ config.content_security_policy_nonce_generator = ->(req) { req.session.id.to_s }
42
+ config.content_security_policy_nonce_directives = %w[script-src style-src]
43
+ end
44
+ ```
45
+
46
+ Then audit Stimulus controllers and Bootstrap usage for inline scripts/styles that need nonces.
47
+
48
+ ## CSRF
49
+
50
+ Rails default — `protect_from_forgery with: :exception` is implicit in `ActionController::Base`. Devise + Turbo handle the token automatically. **Don't** disable CSRF on a controller unless it's a webhook endpoint, in which case use a different authentication mechanism (signature verification).
51
+
52
+ ## XSS
53
+
54
+ - Slim auto-escapes by default. `==` and `raw` and `html_safe` bypass it — never use them on user input.
55
+ - ViewComponents render via Slim, so the same rule applies.
56
+ - `escape_javascript` is used in `OperationsMethods#endpoint`'s `format.js` branch when injecting modal HTML — keep that helper in any new JS-response code that interpolates a string.
57
+
58
+ ## SQL injection
59
+
60
+ - ActiveRecord query methods (`where`, `joins`) auto-escape when given hashes / arrays. Never interpolate user input into raw SQL: prefer `where("name LIKE ?", "%#{q}%")` (parameterized) over `where("name LIKE '%#{q}%'")`.
61
+ - For `order(...)`, user-controlled column names are dangerous — use the `Base::Operation::Sortable#apply_sorting` helper, which validates against an `allowed_columns` allowlist.
62
+
63
+ ## Browser support
64
+
65
+ `ApplicationController` calls `allow_browser versions: :modern`. Old browsers receive a 426 page. This is intentional: it lets us assume modern JS / CSS and simplifies CSP and Hotwire support.
66
+
67
+ ## Devise modules currently enabled
68
+
69
+ `:database_authenticatable, :registerable, :recoverable, :rememberable, :validatable`.
70
+
71
+ NOT enabled (and probably should be, eventually):
72
+
73
+ - `:lockable` — lock account after N failed attempts (sensible to add).
74
+ - `:trackable` — record sign-in count / IP / timestamps.
75
+ - `:confirmable` — email confirmation.
76
+ - `:timeoutable` — auto-logout after inactivity.
77
+
78
+ Adding any of these requires a migration (see Devise docs) plus enabling them in `User`.
79
+
80
+ ## Brakeman
81
+
82
+ `bin/brakeman --no-pager` runs in CI. If it flags something:
83
+
84
+ 1. Fix the underlying issue if possible.
85
+ 2. If it's a false positive, document why with a comment and add an inline ignore (`# brakeman:ignore`) — but prefer fixes over ignores.
86
+
87
+ ## importmap audit
88
+
89
+ `bin/importmap audit` runs in CI and flags JS dependencies with known CVEs. Pinned via `config/importmap.rb`.
90
+
91
+ ## Mass assignment
92
+
93
+ Use **strong parameters** in operations:
94
+
95
+ ```ruby
96
+ def perform!(params:, current_user:)
97
+ attrs = params.require(:property).permit(:name, :description)
98
+ self.model = Crm::Property.new(attrs)
99
+ authorize_and_save!
100
+ end
101
+ ```
102
+
103
+ Never `params.permit!` or `params.to_unsafe_h` unless you have a *very* specific reason and a comment explaining it.
104
+
105
+ ## Webhook / unauthenticated endpoints
106
+
107
+ There are none in the project right now. When the first one lands:
108
+
109
+ - Skip Devise: `skip_before_action :authenticate_user!`
110
+ - Skip CSRF: `protect_from_forgery with: :null_session, only: [:webhook]`
111
+ - Verify the request signature (HMAC, JWT, etc.) inside the operation.
112
+ - `skip_authorize` only after signature verification passes — and document why.
113
+
114
+ ## Anti-patterns
115
+
116
+ - ❌ Skipping authorization in operations (`skip_authorize` without a comment explaining why).
117
+ - ❌ Building SQL with string interpolation.
118
+ - ❌ `params.permit!` / `params.to_unsafe_h`.
119
+ - ❌ `==` / `raw` / `html_safe` in Slim or ViewComponent templates on anything user-controlled.
120
+ - ❌ `before_action :skip_authorization` on an entire controller.
121
+ - ❌ Logging request bodies that contain unfiltered sensitive data.
122
+ - ❌ Using `find_by_sql` with interpolated input.
@@ -0,0 +1,166 @@
1
+ # Stimulus Controllers
2
+
3
+ All JavaScript interactivity uses **Hotwire Stimulus**. Controllers live in `app/javascript/controllers/`.
4
+
5
+ ## File naming & registration
6
+
7
+ Controllers are auto-loaded via `eagerLoadControllersFrom` (`app/javascript/controllers/index.js`) — **no manual registration needed**.
8
+
9
+ - `controllers/dropdown_controller.js` → identifier `dropdown`
10
+ - `controllers/navbar_controller.js` → identifier `navbar`
11
+ - `controllers/some_feature/loader_controller.js` → identifier `some-feature--loader`
12
+
13
+ Rules:
14
+
15
+ - Filename: `snake_case_controller.js`
16
+ - Underscores → hyphens in the identifier
17
+ - Subdirectory separator → `--` in the identifier
18
+ - Group related controllers in a subdirectory
19
+
20
+ ## Controller structure
21
+
22
+ ```js
23
+ import { Controller } from "@hotwired/stimulus"
24
+
25
+ export default class extends Controller {
26
+ static targets = ["input", "result"]
27
+ static values = { delay: { type: Number, default: 300 } }
28
+
29
+ connect() {
30
+ // called when element is added to DOM
31
+ }
32
+
33
+ disconnect() {
34
+ // clean up any external event listeners here
35
+ }
36
+ }
37
+ ```
38
+
39
+ Always `export default class extends Controller` — never name the class.
40
+
41
+ ## Targets, values, outlets
42
+
43
+ - **targets** — DOM references; auto-generates `this.inputTarget`, `this.inputTargets`, `this.hasInputTarget`
44
+ - **values** — reactive typed properties; use typed defaults: `{ type: String, default: 'all' }`
45
+ - **outlets** — cross-controller communication (rare; prefer Turbo events)
46
+
47
+ Use `this.has*Target` guard before accessing optional targets:
48
+
49
+ ```js
50
+ if (this.hasLoaderTarget) {
51
+ this.loaderTarget.style.display = 'none'
52
+ }
53
+ ```
54
+
55
+ ## Event listeners on `document` / outside the element
56
+
57
+ Use **arrow-function class fields** so `this` is preserved, and **always remove them in `disconnect()`**:
58
+
59
+ ```js
60
+ connect() {
61
+ document.addEventListener("turbo:submit-start", this.handleSubmitStart)
62
+ }
63
+
64
+ disconnect() {
65
+ document.removeEventListener("turbo:submit-start", this.handleSubmitStart)
66
+ }
67
+
68
+ handleSubmitStart = (event) => {
69
+ if (this.element.contains(event.target)) { ... }
70
+ }
71
+ ```
72
+
73
+ Never add document-level listeners with `.bind(this)` in `connect()` — the bound function is a new reference each call, so you can't `removeEventListener` it. (Existing controllers like `dropdown_controller.js` use `this.boundXxx = handler.bind(this)` and store the reference; that works too — but arrow-function class fields are simpler.)
74
+
75
+ Always scope handlers to `this.element.contains(event.target)` to avoid reacting to other frames/forms.
76
+
77
+ ## Turbo integration
78
+
79
+ Useful Turbo lifecycle events:
80
+
81
+ - `turbo:load` / `turbo:render` — page navigated
82
+ - `turbo:submit-start` / `turbo:submit-end` — form submission
83
+ - `turbo:before-fetch-request` — any Turbo fetch starts
84
+ - `turbo:frame-load` — Turbo Frame finished loading
85
+
86
+ ## Bootstrap integration
87
+
88
+ Bootstrap is loaded globally and exposed as `window.bootstrap`:
89
+
90
+ ```js
91
+ new window.bootstrap.Modal(this.element).show()
92
+ new window.bootstrap.Tooltip(this.element)
93
+ window.bootstrap.Dropdown.getInstance(this.element)
94
+ ```
95
+
96
+ No import needed.
97
+
98
+ ## Passing data from Rails to Stimulus
99
+
100
+ Use `data-*-value` attributes for scalar values and `data-*-param` on action elements:
101
+
102
+ ```slim
103
+ div data-controller="users-search"
104
+ input data-action="input->users-search#search" data-users-search-target="input"
105
+ button data-action="click->users-search#clear" data-users-search-default-param="all"
106
+ ```
107
+
108
+ ```js
109
+ search() { this.fetchResults(this.inputTarget.value) }
110
+
111
+ clear({ params: { default: defaultValue } }) {
112
+ this.inputTarget.value = defaultValue || ''
113
+ }
114
+ ```
115
+
116
+ ## Debouncing
117
+
118
+ ```js
119
+ connect() { this.timeout = null }
120
+
121
+ search() {
122
+ clearTimeout(this.timeout)
123
+ this.timeout = setTimeout(() => this.performSearch(), this.delayValue)
124
+ }
125
+
126
+ disconnect() { clearTimeout(this.timeout) }
127
+ ```
128
+
129
+ ## What belongs in a Stimulus controller vs. elsewhere
130
+
131
+ - UI interactivity (show/hide, toggle, debounce, modals) — Stimulus
132
+ - Fetching data / form submissions — Turbo (frames, streams); controllers only trigger or react
133
+ - Application-wide state / complex logic — keep minimal; split into focused controllers
134
+ - Third-party widget init (Bootstrap, etc.) — `connect()` / `disconnect()` lifecycle
135
+
136
+ Keep controllers small and single-purpose.
137
+
138
+ ## Existing controllers (reference)
139
+
140
+ | File | Identifier | Purpose |
141
+ |---|---|---|
142
+ | `application.js` | — | Stimulus app bootstrap |
143
+ | `index.js` | — | `eagerLoadControllersFrom` registration |
144
+ | `dropdown_controller.js` | `dropdown` | Bootstrap dropdown lifecycle + click-outside-to-close. Uses `boundXxx = handler.bind(this)` pattern (legacy) — new controllers should prefer arrow-function class fields. |
145
+ | `navbar_controller.js` | `navbar` | Top navbar interactions (mobile collapse, active link highlighting). |
146
+ | `theme_controller.js` | `theme` | Light/dark theme toggle. Persists choice in `localStorage`, sets `data-theme` and `data-bs-theme` on `<html>` and `<body>` (Bootstrap reads `data-bs-theme`). |
147
+ | `hello_controller.js` | `hello` | Stimulus boilerplate — safe to delete once replaced by real controllers. |
148
+
149
+ ### Theme controller pattern
150
+
151
+ `theme_controller.js` is the reference for "persisted UI state" controllers:
152
+
153
+ ```js
154
+ connect() {
155
+ const savedTheme = localStorage.getItem('theme') || 'light'
156
+ this.setTheme(savedTheme)
157
+ }
158
+ ```
159
+
160
+ Notes:
161
+ - Reads `localStorage` in `connect()` so theme survives Turbo navigation.
162
+ - Mirrors state to BOTH `data-theme` (for app CSS) and `data-bs-theme` (for Bootstrap 5).
163
+ - Optional `toggle` target — guarded with `this.hasToggleTarget`.
164
+ - The toggle button can be either an `<i>` icon or a wrapper containing `<i>` — the controller handles both.
165
+
166
+ When you add similar persisted UI controllers (sidebar collapse, font-size, density), follow the same shape: read in `connect`, write in `setX`, no listeners on `document`.