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,315 @@
1
+ # Architecture
2
+
3
+ ## Concepts Pattern (primary feature organization)
4
+
5
+ All features live in `app/concepts/<namespace>/<feature>/` with two sub-directories:
6
+
7
+ - **`operation/`** — business logic, authorization, data loading
8
+ - **`component/`** — ViewComponent UI classes + Slim templates
9
+
10
+ Top-level namespaces (illustrative — name them after your access tiers):
11
+
12
+ - `admin/` — administrators (`user.admin?`)
13
+ - `crm/` — business users (`user.owner?`)
14
+ - `public/` — unauthenticated visitors / end consumers
15
+ - `base/` — shared building blocks (`Base::Operation::Base`, `Base::Component::Base`, `Base::Component::Btn`, `Base::Component::Table::Table`, `Base::Component::TitleRow`, `Base::Operation::Sortable`)
16
+ - `shared/` — cross-domain UI (sidebars, navbars)
17
+
18
+ `config/initializers/view_component.rb` sets `view_component_path = "app/concepts"`. ViewComponent resolves templates inside the concepts tree, NOT under `app/views/`. `app/views/` only holds layouts (one per top-level namespace, e.g. `admin.slim`, `crm.slim`, `public.slim`), Devise views, and PWA stubs.
19
+
20
+ Controllers are thin wrappers that call `endpoint`:
21
+
22
+ ```ruby
23
+ class Admin::UsersController < Admin::BaseController
24
+ def index
25
+ endpoint Admin::User::Operation::Index, Admin::User::Component::Index
26
+ end
27
+
28
+ def show
29
+ endpoint Admin::User::Operation::Show, Admin::User::Component::Show
30
+ end
31
+ end
32
+ ```
33
+
34
+ The `endpoint` method (defined in `app/controllers/concerns/operations_methods.rb`) runs the operation, extracts `result.model`, and passes it to the component for rendering.
35
+
36
+ ### How `endpoint` dispatches
37
+
38
+ `endpoint` decides the response based on `action_name` and the response format:
39
+
40
+ - **`format.html`** — for `index/show/edit/new` it renders the component; for `create/update/destroy` it redirects to `result.redirect_path` or `<controller_name>_path`. Flash from `result.message` / `result.error_message`. On `create/update` failure it re-renders the component with HTTP 422.
41
+ - **`format.js`** — used for Bootstrap modal new/edit dialogs. Renders the component into `#modals` via JS or redirects on success.
42
+ - **`format.json`** — used by select2 search. Returns `result.model.map(&:select2_search_result)` with pagination.
43
+ - **`format.any`** — fallback for auto-submit/null requests.
44
+
45
+ Component constructor kwargs are derived from `result.model`:
46
+
47
+ - If `result.model` is an `OpenStruct` — splatted as kwargs (`component.new(**result.model.to_h)`).
48
+ - Otherwise — passed as `<concept>: model` (singular for show/edit/new, pluralized for index), where `<concept>` is the operation's top-level namespace underscored (e.g. `Admin::User::Operation::Index` → `admin: ...`). Keep this naming in sync between operation and component when not using `OpenStruct`.
49
+
50
+ ### `format.js` — Bootstrap modal flow
51
+
52
+ For new/edit dialogs that should render inside an already-loaded page (no full navigation), submit the form with `data-turbo: false` and let the response come back as JS. `endpoint`'s `format.js` branch:
53
+
54
+ - **Success on create/update/destroy** → emits `window.location.href = '<path>'`. The browser navigates to `result.redirect_path` (or the `<controller_name>_path` fallback).
55
+ - **Failure / new / edit** → renders the component to a string, hides any currently-open Bootstrap modal, then injects the rendered HTML into `<div id="modals"></div>` and shows it. The injected HTML must contain a `.modal` element at the root.
56
+
57
+ Required scaffolding on the page:
58
+
59
+ ```slim
60
+ / in the layout / shared partial
61
+ #modals
62
+ ```
63
+
64
+ ```ruby
65
+ / trigger button
66
+ render ::Base::Component::Btn.new(
67
+ type: 'add',
68
+ text: I18n.t('admin.users.new'),
69
+ path: new_admin_user_path,
70
+ data: { remote: true, turbo: false } # request format.js
71
+ )
72
+ ```
73
+
74
+ The component template should wrap content in a Bootstrap modal:
75
+
76
+ ```slim
77
+ .modal.fade tabindex="-1" id="user_modal"
78
+ .modal-dialog
79
+ .modal-content
80
+ .modal-header
81
+ h5.modal-title = I18n.t('admin.users.new.title')
82
+ button.btn-close type="button" data-bs-dismiss="modal"
83
+ .modal-body
84
+ / form
85
+ ```
86
+
87
+ `escape_javascript` is used to safely interpolate the rendered HTML into the JS response — see `OperationsMethods#endpoint`. Don't bypass it.
88
+
89
+ ### `format.json` — select2 search
90
+
91
+ The JSON branch is wired for select2 (jQuery select2 with remote data). Operation requirements:
92
+
93
+ - `result.model` is either a paginated relation OR an `OpenStruct` with a single pluralized key (e.g. `users:`).
94
+ - Each record must implement `#select2_search_result` returning `{ id:, text: }`.
95
+ - Pagination is detected via `respond_to?(:next_page)` (works with kaminari/pagy when configured).
96
+
97
+ ```ruby
98
+ class User
99
+ def select2_search_result
100
+ { id: id, text: "#{name} <#{email}>" }
101
+ end
102
+ end
103
+ ```
104
+
105
+ The response shape:
106
+
107
+ ```json
108
+ { "result": [{"id": 1, "text": "..."}], "pagination": { "more": true } }
109
+ ```
110
+
111
+ ### Sub-operations in detail
112
+
113
+ `run_operation(OtherOp, params: ..., current_user: ...)` calls another operation, appends its `Result` to `result.sub_results`, and bubbles failures by default:
114
+
115
+ ```ruby
116
+ def perform!(params:, current_user:)
117
+ self.model = Crm::Property.new(name: params[:property_name])
118
+ authorize_and_save!
119
+
120
+ run_operation(
121
+ Crm::Property::Operation::Notify,
122
+ params: params,
123
+ current_user: current_user
124
+ )
125
+ # If the sub-operation fails, its errors are copied onto self and ActiveRecord::RecordInvalid is raised.
126
+ end
127
+ ```
128
+
129
+ To handle sub-operation errors yourself (e.g. swallow them or branch logic), pass `manually_handle_errors: true`:
130
+
131
+ ```ruby
132
+ sub_result = run_operation(
133
+ OtherOp,
134
+ params: params,
135
+ current_user: current_user,
136
+ manually_handle_errors: true
137
+ )
138
+
139
+ if sub_result.failure?
140
+ add_error :base, I18n.t('alerts.partial_failure')
141
+ invalid!
142
+ end
143
+ ```
144
+
145
+ `result.success?` / `result.failure?` consider the operation's own errors AND every sub-result, so authorization in sub-operations is enforced just like top-level ones.
146
+
147
+ ### When to bypass `endpoint`
148
+
149
+ Use `endpoint` for every standard CRUD action. There is no `endpoint_partial` or `endpoint_json` helper in this project (yet). For ad-hoc Turbo Frame fragments or JSON endpoints, render directly from the controller and call `check_authorization_is_called(result)` after the operation, OR have the operation `skip_authorize` + `skip_policy_scope` and let the helper observe the flags.
150
+
151
+ ## Operations (`Base::Operation::Base`)
152
+
153
+ ```ruby
154
+ # frozen_string_literal: true
155
+
156
+ class Admin::User::Operation::Index < Base::Operation::Base
157
+ include Base::Operation::Sortable
158
+
159
+ def perform!(params:, current_user:)
160
+ self.model = ::OpenStruct.new(users: nil)
161
+
162
+ users = policy_scope(User)
163
+
164
+ users = apply_sorting(
165
+ users,
166
+ params: params,
167
+ allowed_columns: %i[id name email role created_at],
168
+ default_column: :id,
169
+ default_direction: :desc
170
+ )
171
+
172
+ self.model.users = users
173
+ end
174
+ end
175
+ ```
176
+
177
+ Always use **compact class notation**: `class Feature::Operation::Action < Base::Operation::Base` — never nested `module` blocks.
178
+
179
+ Key methods available inside operations:
180
+
181
+ - `authorize!(record, query)` / `policy_scope(scope)` — Pundit authorization (mandatory in every operation; see below)
182
+ - `skip_authorize` / `skip_policy_scope` — bypass auth checks; usually used together
183
+ - `self.model = value` — data passed to component
184
+ - `self.redirect_path = path` — redirect after action (triggers redirect in `endpoint`)
185
+ - `notice(text, level: :notice)` — flash message
186
+ - `add_error(key, message)` / `add_errors(from)` / `invalid!` — failure handling
187
+ - `authorize_and_save!(auth_method = nil)` — `authorize!` then `model.save!` (defaults to `:create?` for new records, `:update?` otherwise)
188
+ - `run_operation(OperationClass, params)` — sub-operations; sub-results bubble failures unless `manually_handle_errors: true`
189
+
190
+ Result (`Base::Operation::Result` — includes `ActiveModel::Validations`):
191
+
192
+ - `result.success?` / `result.failure?` — considers `errors`, `model.errors`, and all `sub_results`
193
+ - `result.model` / `result.errors` / `result.message` / `result.message_level` / `result.redirect_path`
194
+ - `result.error_message` — `errors[:base].join(' ')`
195
+ - `result[:key]` — stash extra data via `@result[key] = ...`
196
+ - `invalid!` — force-fail even if no errors are recorded
197
+
198
+ ### `OpenStruct` as model
199
+
200
+ Use `::OpenStruct` to pass multiple values to a component. `endpoint` spreads its keys as kwargs:
201
+
202
+ ```ruby
203
+ self.model = ::OpenStruct.new(users: paginated, request_params: params)
204
+
205
+ # component receives:
206
+ def initialize(users:, request_params:)
207
+ ```
208
+
209
+ When a slim template needs request params (search forms, sorting), pass them through the OpenStruct as `request_params:` rather than reaching for `params` inside the component.
210
+
211
+ ## Authorization (Pundit) — MANDATORY
212
+
213
+ Every operation MUST call one of:
214
+
215
+ - `authorize!(record, :action?)`
216
+ - `policy_scope(scope)`
217
+ - `skip_authorize` (alone — `OperationsMethods#check_authorization_is_called` already skips `policy_scope` on failure or `[:pundit_scope]`)
218
+ - `authorize_and_save!`
219
+
220
+ `OperationsMethods#check_authorization_is_called` enforces this after the operation runs by calling `skip_authorization` / `skip_policy_scope` only when the operation set `result[:pundit]` / `result[:pundit_scope]` (or the operation failed). Forgetting to call any of them → `Pundit::AuthorizationNotPerformedError`.
221
+
222
+ ### Policies
223
+
224
+ Policies live in `app/policies/` and inherit from `ApplicationPolicy`. There are three per-domain base policies — they are also used by `ApplicationController#user_not_authorized` to decide where to redirect on `Pundit::NotAuthorizedError`:
225
+
226
+ - `Admin::BasePolicy` — `user.admin?`. Denied → `crm_root_path` for non-admin staff, otherwise root.
227
+ - `Crm::BasePolicy` — `user.admin? || user.owner?` (extend with the staff roles your app needs). Denied → root.
228
+ - `Public::BasePolicy` — anonymous-friendly base for unauthenticated layouts.
229
+
230
+ `Admin::BaseController` runs `Admin::BasePolicy#index?` in a `before_action` to gate the whole namespace.
231
+
232
+ Policy patterns in this project:
233
+
234
+ ```ruby
235
+ class Crm::PropertyPolicy < Crm::BasePolicy
236
+ def update?
237
+ return false unless crm_access?
238
+ return true if user.admin?
239
+ return true if user.owner? && record.owner_id == user.id
240
+
241
+ false
242
+ end
243
+
244
+ class Scope < Crm::BasePolicy::Scope
245
+ def resolve
246
+ return ::Crm::Property.none unless crm_access?
247
+
248
+ if user.admin?
249
+ scope.all
250
+ elsif user.owner?
251
+ scope.where(owner_id: user.id)
252
+ else
253
+ ::Crm::Property.none
254
+ end
255
+ end
256
+ end
257
+ end
258
+ ```
259
+
260
+ ## Components (`Base::Component::Base`)
261
+
262
+ ```ruby
263
+ # frozen_string_literal: true
264
+
265
+ class Admin::User::Component::Index < Base::Component::Base
266
+ def initialize(users:)
267
+ @users = users
268
+ end
269
+ end
270
+ ```
271
+
272
+ Template at `app/concepts/admin/user/component/index.html.slim`. Components are pure presentation — they receive data via `initialize` and never fetch it themselves.
273
+
274
+ `Base::Component::Base` exists in this project (`app/concepts/base/component/base.rb`) — it's a thin subclass of `ViewComponent::Base`. Feature components inherit from it. Generic base UI primitives (`Base::Component::Btn`, `Base::Component::Table::Table`) inherit directly from `ViewComponent::Base` because they are imported elsewhere.
275
+
276
+ Use `::` prefix when referencing other components from namespaced contexts (e.g. `::Base::Component::Btn`).
277
+
278
+ ## Sorting
279
+
280
+ `Base::Operation::Sortable` provides `apply_sorting(relation, params:, allowed_columns:, default_column:, default_direction:)` for index operations. Always pass an `allowed_columns` allowlist — anything outside it falls back to the default.
281
+
282
+ ```ruby
283
+ include Base::Operation::Sortable
284
+
285
+ users = apply_sorting(
286
+ policy_scope(User),
287
+ params: params,
288
+ allowed_columns: %i[id name email role created_at],
289
+ default_column: :id,
290
+ default_direction: :desc
291
+ )
292
+ ```
293
+
294
+ ## Domain model (illustrative — substitute your own)
295
+
296
+ The examples above assume `User` (Devise-authenticated, with a `role` enum) and a representative business resource (`Crm::Property`). Replace with your domain:
297
+
298
+ ```ruby
299
+ class User < ApplicationRecord
300
+ enum :role, { admin: 0, owner: 1, customer: 2 }
301
+ has_many :owned_properties, class_name: "Crm::Property", foreign_key: "owner_id"
302
+ end
303
+
304
+ class Crm::Property < ApplicationRecord
305
+ belongs_to :owner, class_name: "User", foreign_key: "owner_id"
306
+ end
307
+ ```
308
+
309
+ Roles in the policy examples (`user.admin?`, `user.owner?`) come from this enum. Customise the role list to fit your access model.
310
+
311
+ ## I18n
312
+
313
+ Locale files live in `config/locales/`. Default locale and fallbacks are configured in `config/application.rb`. Always use `I18n.t('full.key')` — never `t('.relative_key')` shorthand inside components/operations, since ViewComponent doesn't resolve relative keys the way Action View does.
314
+
315
+ `ApplicationController#user_not_authorized` reads I18n keys for redirect-on-deny messages (e.g. `authorization.admin_access_denied`, `authorization.crm_access_denied`, `authorization.action_access_denied`). Add those keys to your locale files; see `.claude/docs/i18n.md`.
@@ -0,0 +1,96 @@
1
+ # Authentication (Devise)
2
+
3
+ Auth uses **Devise** with the modules: `:database_authenticatable, :registerable, :recoverable, :rememberable, :validatable` (see `app/models/user.rb`). Lockable / confirmable / trackable / timeoutable / omniauthable are NOT enabled — don't reach for `confirmation_sent_at` etc. unless you also turn them on.
4
+
5
+ ## Routes
6
+
7
+ ```ruby
8
+ # config/routes.rb
9
+ devise_for :users, controllers: { registrations: "users/registrations" }
10
+ ```
11
+
12
+ Only `registrations` is overridden. Sessions and passwords use Devise defaults.
13
+
14
+ ## Permitted parameters
15
+
16
+ `ApplicationController#configure_permitted_parameters` runs on every Devise request:
17
+
18
+ ```ruby
19
+ devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :role])
20
+ devise_parameter_sanitizer.permit(:account_update, keys: [:name, :role])
21
+ ```
22
+
23
+ When you add a new column the user can edit, add it to both `:sign_up` and `:account_update`.
24
+
25
+ ## Custom registration flow (optional)
26
+
27
+ If your app needs a richer sign-up — for example, atomically creating a related resource (an organisation, a tenant, a profile) when a user picks the "owner" role — override Devise's `RegistrationsController`:
28
+
29
+ ```ruby
30
+ # config/routes.rb
31
+ devise_for :users, controllers: { registrations: "users/registrations" }
32
+ ```
33
+
34
+ ```ruby
35
+ # app/controllers/users/registrations_controller.rb
36
+ class Users::RegistrationsController < Devise::RegistrationsController
37
+ def create
38
+ ActiveRecord::Base.transaction do
39
+ build_resource(sign_up_params)
40
+ resource.save || (raise ActiveRecord::Rollback)
41
+ # create related records here, then promote roles if needed
42
+ end
43
+ super
44
+ end
45
+ end
46
+ ```
47
+
48
+ Why a transaction: cross-record validations ("owner must have a related X" + "X must have an owner") create a chicken-and-egg. A transaction lets you save the User first as a base role, build dependents inside the same transaction (with `save(validate: false)` for the inner record), then promote the User and re-validate everything at commit.
49
+
50
+ ## Where redirects go after sign-up
51
+
52
+ ```ruby
53
+ def after_sign_up_path_for(resource)
54
+ resource.owner? ? crm_root_path : super
55
+ end
56
+ ```
57
+
58
+ Override `after_sign_up_path_for` only if a specific role needs a non-default landing page. Otherwise Devise's default (`stored_location_for(resource) || root_path`) is correct.
59
+
60
+ ## Sign-out / unauthorized redirects
61
+
62
+ Pundit failures route through `ApplicationController#user_not_authorized` — see `routing-and-namespaces.md` for the redirect matrix.
63
+
64
+ ## I18n
65
+
66
+ Devise strings live in `config/locales/devise.<locale>.yml` (separate from your app locales). The custom-flow flash messages use:
67
+
68
+ - `set_flash_message! :notice, :signed_up` — Devise's standard key
69
+ - `I18n.t("authorization.admin_access_denied" | "crm_access_denied" | "action_access_denied")` — defined in your app locales
70
+
71
+ When you add a new flash, decide: Devise-flow → `devise.<locale>.yml`; app-flow → your app locales.
72
+
73
+ ## Stubbing auth in tests
74
+
75
+ ```ruby
76
+ # Controller spec
77
+ before do
78
+ allow(controller).to receive(:authenticate_user!).and_return(true)
79
+ allow(controller).to receive(:current_user).and_return(user)
80
+ end
81
+
82
+ # Operation spec — pass current_user explicitly
83
+ described_class.call(params: params, current_user: build(:user, :owner) # adjust trait to match your factories)
84
+ ```
85
+
86
+ For request specs, `sign_in(user)` works (Devise test helpers are loaded via `rails_helper`).
87
+
88
+ ## Adding new fields
89
+
90
+ Steps when adding a new attribute users can set during sign-up or edit:
91
+
92
+ 1. Migration → add column.
93
+ 2. Model → validation if needed.
94
+ 3. `ApplicationController#configure_permitted_parameters` → add the attribute to `:sign_up` and/or `:account_update`.
95
+ 4. Devise views (`app/views/devise/...`) → add the field to the form.
96
+ 5. Locales → add the label/placeholder to `devise.<locale>.yml` and your app locales.
@@ -0,0 +1,135 @@
1
+ # Background Jobs (SolidQueue)
2
+
3
+ The app uses **SolidQueue** (database-backed Active Job adapter) with a dedicated queue database. There are no jobs yet — `app/jobs/` only contains `application_job.rb`. This doc fixes conventions before the first real job lands.
4
+
5
+ ## Configuration
6
+
7
+ ```
8
+ config/queue.yml # Worker / dispatcher config
9
+ config/recurring.yml # Cron-style recurring tasks
10
+ db/queue_schema.rb # Schema for the queue database
11
+ ```
12
+
13
+ `config/queue.yml` (default workers):
14
+
15
+ - 1 dispatcher, polling every 1s, batch size 500.
16
+ - Workers process all queues (`*`), 3 threads each, polling every 0.1s.
17
+ - Process count via `JOB_CONCURRENCY` env var (default 1).
18
+
19
+ `config/recurring.yml` already schedules:
20
+
21
+ ```yaml
22
+ production:
23
+ clear_solid_queue_finished_jobs:
24
+ command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
25
+ schedule: every hour at minute 12
26
+ ```
27
+
28
+ In production deploys (Kamal), SolidQueue runs **inside Puma** via `SOLID_QUEUE_IN_PUMA=true` (set in `config/deploy.yml`). When traffic grows, split it onto a dedicated job server.
29
+
30
+ ## Where jobs live
31
+
32
+ ```
33
+ app/jobs/
34
+ application_job.rb # Base class (queue_as :default by default)
35
+ <namespace>/<feature>_job.rb # Feature-specific jobs, mirroring app/concepts naming
36
+ ```
37
+
38
+ For feature-scoped jobs prefer `app/jobs/<namespace>/<job>.rb` (e.g. `app/jobs/crm/property/cleanup_job.rb`). Don't put them inside `app/concepts/` — jobs are infrastructure, not part of the request/operation cycle.
39
+
40
+ ## Naming & class layout
41
+
42
+ ```ruby
43
+ # app/jobs/crm/property/cleanup_job.rb
44
+ # frozen_string_literal: true
45
+
46
+ class Crm::Property::CleanupJob < ApplicationJob
47
+ queue_as :default
48
+
49
+ def perform(property_id)
50
+ property = Crm::Property.find_by(id: property_id)
51
+ return if property.nil?
52
+
53
+ Crm::Property::Operation::Cleanup.call(
54
+ params: { property_id: property.id },
55
+ current_user: nil
56
+ )
57
+ end
58
+ end
59
+ ```
60
+
61
+ Rules:
62
+
63
+ - Always `# frozen_string_literal: true`.
64
+ - `queue_as :default` unless you have a specific reason (e.g. `:background` for non-time-sensitive bulk work).
65
+ - **Pass IDs, not AR objects** — by the time the job runs, the record may be gone.
66
+ - **Idempotent perform** — workers retry on failure, and recurring tasks may overlap.
67
+ - Delegate real logic to an Operation (`<Feature>::Operation::<Action>`). The job is a thin wrapper that pulls IDs and calls the operation, NOT a place for business logic.
68
+ - Set `retry_on` / `discard_on` for known failures (e.g. `discard_on ActiveJob::DeserializationError` for missing records).
69
+
70
+ ## Calling from an operation
71
+
72
+ ```ruby
73
+ # Inside Operation#perform!
74
+ Crm::Property::CleanupJob.perform_later(model.id)
75
+ ```
76
+
77
+ For tests, prefer `perform_later` + assert enqueued (see below); use `perform_now` only if you want sync execution.
78
+
79
+ ## Recurring tasks
80
+
81
+ Add new entries to `config/recurring.yml` under the `production:` key (or `default:` if you want them to run in dev too):
82
+
83
+ ```yaml
84
+ production:
85
+ daily_archive:
86
+ class: Crm::Archive::DailyJob
87
+ queue: background
88
+ schedule: every day at 3am
89
+
90
+ cleanup:
91
+ command: "Crm::Stale.delete_all"
92
+ schedule: every hour at minute 12
93
+ ```
94
+
95
+ Use `class:` for jobs that take no arguments; use `command:` for one-off Ruby snippets. Provide a unique top-level key for each task.
96
+
97
+ ## Testing
98
+
99
+ ```ruby
100
+ # rails_helper.rb already loads ActiveJob test helpers.
101
+ RSpec.describe Crm::Property::CleanupJob do
102
+ let(:property) { create(:property) }
103
+
104
+ it 'enqueues with the property id' do
105
+ expect {
106
+ described_class.perform_later(property.id)
107
+ }.to have_enqueued_job.with(property.id).on_queue('default')
108
+ end
109
+
110
+ it 'calls the cleanup operation' do
111
+ op_double = class_double(Crm::Property::Operation::Cleanup, call: nil)
112
+ stub_const('Crm::Property::Operation::Cleanup', op_double)
113
+
114
+ described_class.perform_now(property.id)
115
+
116
+ expect(op_double).to have_received(:call).with(
117
+ params: { property_id: property.id }, current_user: nil
118
+ )
119
+ end
120
+ end
121
+ ```
122
+
123
+ When testing operations that enqueue jobs:
124
+
125
+ ```ruby
126
+ expect { result }.to have_enqueued_job(Crm::Property::CleanupJob).with(property.id)
127
+ ```
128
+
129
+ ## Anti-patterns
130
+
131
+ - ❌ Passing AR objects: `MyJob.perform_later(user)` → fragile across deserialization. Use `user.id`.
132
+ - ❌ Business logic in `perform` — keep it in an Operation.
133
+ - ❌ Long-running synchronous calls inside `Operation#perform!` — push them to a job.
134
+ - ❌ Skipping `retry_on`/`discard_on` — defaults will retry forever.
135
+ - ❌ Using `:default` queue for everything when some work is genuinely lower-priority — split queues so a backlog of slow tasks doesn't starve fast ones.
@@ -0,0 +1,101 @@
1
+ # Code Style
2
+
3
+ ## Ruby / Rails
4
+
5
+ - `# frozen_string_literal: true` on the first line of every Ruby file under `app/` and `spec/`.
6
+ - RuboCop is `rubocop-rails-omakase` (see `.rubocop.yml`). Run with `bin/rubocop` and autocorrect via `bin/rubocop -A`.
7
+ - Compact class notation: `class Feature::Operation::Action < Base::Operation::Base`. Never nested `module` blocks.
8
+ - Business logic belongs in operations under `app/concepts/<...>/operation/`. Controllers are `endpoint Op, Component` one-liners; models hold validations/associations only.
9
+ - Trailing commas in multi-line arrays and hashes (Omakase enforces it).
10
+ - Use `Time.zone.parse(...)` rather than `DateTime.parse(...)` (Rails timezone-aware).
11
+
12
+ ## I18n
13
+
14
+ - Default locale is set in `config/application.rb`. Every locale file under `config/locales/` must be kept in sync (mirror the same keys).
15
+ - Always `I18n.t('full.key')` in operations and components — never `t('key')` or `t('.relative_key')` shorthand.
16
+ - Devise strings are split into `config/locales/devise.en.yml`.
17
+
18
+ ## ViewComponent / Slim
19
+
20
+ - View component path is `app/concepts/` (`config/initializers/view_component.rb`). Templates live next to the `.rb` file: `app/concepts/<ns>/<feature>/component/<action>.html.slim`. (ViewComponent 4.8+ requires the explicit `.html.` prefix; Rails layouts under `app/views/layouts/` keep the bare `.slim` extension.)
21
+ - Feature components inherit from `Base::Component::Base`; generic base primitives (`Btn`, `Table`) inherit from `ViewComponent::Base`.
22
+ - Use `::Base::Component::Btn` (with `::` prefix) when referencing components from namespaced contexts.
23
+ - Templates: keep logic minimal — push Ruby into the component `.rb` file. Slim auto-escapes; never use `==`/`raw`/`html_safe` on user input.
24
+
25
+ ## Forms
26
+
27
+ - Use `simple_form_for` (gem `simple_form`). Forms must call it via `helpers.simple_form_for` inside ViewComponents:
28
+
29
+ ```slim
30
+ = helpers.simple_form_for @model, url: some_path, method: :post do |f|
31
+ ```
32
+
33
+ - New/Edit pattern: extract a shared `Form` component; create distinct `New`/`Edit` subclasses (for breadcrumbs/title differences).
34
+ - On validation failure inside an operation: `add_error :base, message` + `invalid!` and re-`self.model = ...`. `endpoint` will re-render with 422.
35
+
36
+ ## File layout cheatsheet
37
+
38
+ ```
39
+ app/concepts/<namespace>/<feature>/
40
+ operation/<action>.rb # business logic, must call authorize!/policy_scope/skip_authorize
41
+ component/<action>.rb # ViewComponent::Base subclass
42
+ component/<action>.html.slim # template
43
+ ```
44
+
45
+ Mirror the same structure under `spec/concepts/...` for tests.
46
+
47
+ # Anti-patterns checklist
48
+
49
+ Real examples of patterns that show up in code reviews. The ✅ side is what to write.
50
+
51
+ ## Operations / Controllers
52
+
53
+ - ❌ AR queries in controllers → ✅ controller is `endpoint Op, Component`; queries live in the operation.
54
+ - ❌ `if/else` flow control in controllers → ✅ branch inside the operation; let `endpoint` handle redirect vs render.
55
+ - ❌ Forgetting Pundit (`Pundit::AuthorizationNotPerformedError` at runtime) → ✅ every operation calls `authorize!` / `policy_scope` / `skip_authorize` / `authorize_and_save!`.
56
+ - ❌ Nested module blocks (`module Admin; class User; ...`) → ✅ compact: `class Admin::User::Operation::Index < Base::Operation::Base`.
57
+ - ❌ Business logic in `before_save` callbacks → ✅ run it inside the operation that mutates the record.
58
+ - ❌ `params.permit!` / `params.to_unsafe_h` → ✅ explicit `params.require(:user).permit(:name, :email, ...)`.
59
+
60
+ ## Components / Views
61
+
62
+ - ❌ AR queries inside a ViewComponent → ✅ data only via `initialize`; operation loads it.
63
+ - ❌ Reading `helpers.params` inside a component → ✅ pass through `OpenStruct` as `request_params:`.
64
+ - ❌ `==`, `raw`, `html_safe` on user input → ✅ trust Slim's auto-escape; if you need HTML, sanitize via Rails' `sanitize` helper with an allowlist.
65
+ - ❌ Inline literal strings in templates → ✅ `I18n.t('full.key')` with mirrors in every locale you ship.
66
+ - ❌ `t('.relative_key')` shorthand → ✅ `I18n.t('full.key')`. ViewComponent's lookup path makes relative keys unreliable.
67
+ - ❌ Raw `<form>` or `form_with` → ✅ `helpers.simple_form_for` (see `forms.md`).
68
+ - ❌ Raw `<button>`/`<a class="btn ...">` → ✅ `render ::Base::Component::Btn.new(...)`.
69
+ - ❌ Hand-rolled `<table>` for collections → ✅ `Base::Component::Table::Table` with `add_column`.
70
+ - ❌ Top-level constant references (e.g. `Base::Component::Btn`) inside another component → ✅ `::Base::Component::Btn.new(...)`. Without `::` Ruby's lookup falls through `ViewComponent::Base::Component` and crashes at render time.
71
+
72
+ ## Visual / design-system
73
+
74
+ See `design-system.md` for the full token set + component catalog. Frequent traps:
75
+
76
+ - ❌ Hardcoded hex literals (`#0d6efd`) in component templates → ✅ design-system tokens (`var(--bs-primary)` or your own SCSS variable).
77
+ - ❌ Hardcoded `box-shadow` / `border-radius` values per component → ✅ shared `--bs-border-radius`, `--bs-box-shadow` tokens.
78
+ - ❌ Hardcoded `font-family` overrides per component → ✅ inherit from the cascade; set fonts once in your stylesheet entry.
79
+ - ❌ `class="bg-primary text-white text-center fw-bold p-3"` repeated everywhere → ✅ extract a small SCSS utility class or component partial.
80
+ - ❌ `active_nav_class('/crm')` for a dashboard link (matches every CRM page) → ✅ `active_nav_class('/crm', '/crm/dashboard', exact: true)`.
81
+ - ❌ Hardcoded brand strings (year, tagline, copyright) in templates → ✅ `I18n.t('ui.brand.year' / 'ui.brand.tagline')`.
82
+
83
+ ## Time / dates
84
+
85
+ - ❌ `Time.now`, `DateTime.parse(...)`, `Date.today` → ✅ `Time.zone.now`, `Time.zone.parse(...)`, `Date.current` (timezone-aware, honors `config.time_zone`).
86
+ - ❌ Hardcoded format strings repeated everywhere → ✅ `Base::Component::Table::Table#format_date`, or register in `config/locales/*.yml` under `date.formats.*`.
87
+
88
+ ## Tests
89
+
90
+ - ❌ `FactoryBot.create(...)` → ✅ shorthand `create(...)` (FactoryBot syntax included globally).
91
+ - ❌ Hardcoded fixtures (`email: "test@test.com"`) → ✅ `Faker::Internet.unique.email`.
92
+ - ❌ `double` when an interface exists → ✅ `instance_double(SomeClass)`.
93
+ - ❌ `expect(x).to receive(:y)` in `before` → ✅ `allow` + `have_received` after the call.
94
+ - ❌ Skipping the Pundit-failure test for an operation → ✅ always cover both happy and `Pundit::NotAuthorizedError` paths.
95
+
96
+ # Git Workflow
97
+
98
+ - Commit messages in **imperative present tense** ("Add feature", not "Added feature"), under 72 chars.
99
+ - Default branch is `main`. Feature branches PR into `main`.
100
+ - Before requesting review: run `bin/rubocop`, `bundle exec rspec`, and `bin/brakeman --no-pager` (all three are part of CI).
101
+ - Never force-push to `main`.
@@ -0,0 +1,34 @@
1
+ # Commands
2
+
3
+ ## Development
4
+
5
+ ```bash
6
+ bin/setup # bundle install + db:prepare + start dev
7
+ bin/dev # foreman: rails server + dartsass:watch (Procfile.dev)
8
+ bin/rails db:prepare # create + migrate (or load schema)
9
+ bin/rails zeitwerk:check # autoloading sanity check
10
+ ```
11
+
12
+ ## Tests
13
+
14
+ ```bash
15
+ bundle exec rspec # full suite
16
+ bundle exec rspec spec/path/to/file_spec.rb # single file
17
+ bundle exec rspec spec/path/to/file_spec.rb:42 # single example by line
18
+ bundle exec rspec --tag focus # by tag
19
+ ```
20
+
21
+ ## Linting & security
22
+
23
+ ```bash
24
+ bin/rubocop # style check (rubocop-rails-omakase)
25
+ bin/rubocop -A # autocorrect all safe offenses
26
+ bin/brakeman --no-pager # static security analysis
27
+ bin/importmap audit # JS dependency audit
28
+ ```
29
+
30
+ CI (`.github/workflows/ci.yml`) runs **brakeman + importmap audit + rubocop + rspec** against PostgreSQL 16.
31
+
32
+ ## Deploy
33
+
34
+ This app uses **Kamal** (`config/deploy.yml`). `bin/kamal deploy` for production deploys; managed by the project owner.