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,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.
|