Skip to content

Commit 9f6c538

Browse files
authored
Merge branch 'main' into dependabot/bundler/omniauth-2.1.4
2 parents ab45dd6 + 81b9d8f commit 9f6c538

28 files changed

Lines changed: 257 additions & 161 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,4 @@ out/
8989

9090
.vscode/
9191
.aider*
92+
.claude

CLAUDE.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Human Essentials is a Ruby on Rails inventory management system for diaper banks and essentials banks. It's a Ruby for Good project serving 200+ non-profit organizations. The app manages donations, purchases, distributions, inventory, partners, and requests for essential items.
8+
9+
## Common Commands
10+
11+
### Development
12+
```bash
13+
bin/setup # First-time setup (installs gems, creates DB, seeds)
14+
bin/start # Starts Rails server (port 3000) + Delayed Job worker
15+
```
16+
17+
### Testing
18+
```bash
19+
bundle exec rspec # Run full test suite
20+
bundle exec rspec spec/models/item_spec.rb # Run a single test file
21+
bundle exec rspec spec/models/item_spec.rb:42 # Run a single test at line
22+
bundle exec rspec spec/models/ # Run a directory of tests
23+
```
24+
25+
CI splits tests into two workflows: `rspec` (unit tests, excludes system/request specs) and `rspec-system` (system and request specs only, 6 parallel nodes). System tests use Capybara with Cuprite (headless Chrome).
26+
27+
### Linting
28+
```bash
29+
bundle exec rubocop # Ruby linter (Standard-based config)
30+
bundle exec erb_lint --lint-all # ERB template linter
31+
bundle exec brakeman # Security scanner
32+
```
33+
34+
### Database
35+
```bash
36+
bundle exec rake db:migrate
37+
bundle exec rake db:seed
38+
bundle exec rake db:reset # Drop + create + migrate + seed
39+
```
40+
41+
## Architecture
42+
43+
### Multi-Tenancy
44+
Nearly all data is scoped to an `Organization`. Most models `belong_to :organization` and queries should always scope by organization context. The current user's organization is the primary tenant boundary.
45+
46+
### Roles (Rolify)
47+
Four roles defined in `Role`: `ORG_USER`, `ORG_ADMIN`, `SUPER_ADMIN`, `PARTNER`. Roles are polymorphic and scoped to a resource (usually an Organization). Authentication is via Devise.
48+
49+
### Event Sourcing for Inventory
50+
Inventory is **not** tracked via simple column updates. Instead, it uses an event sourcing pattern:
51+
52+
- **`Event`** (STI base model) stores all inventory-affecting actions as JSONB events
53+
- Subclasses: `DonationEvent`, `DistributionEvent`, `PurchaseEvent`, `TransferEvent`, `AdjustmentEvent`, `AuditEvent`, `KitAllocateEvent`, `SnapshotEvent`, etc.
54+
- **`InventoryAggregate`** replays events to compute current inventory state. It finds the most recent `SnapshotEvent` and replays subsequent events
55+
- **`EventTypes::Inventory`** is the in-memory inventory representation built from events
56+
- When creating/updating donations, distributions, purchases, transfers, or adjustments, the corresponding service creates an Event, and `Event#validate_inventory` replays all events to verify consistency
57+
58+
This means: to check inventory levels, use `InventoryAggregate.inventory_for(organization_id)`, not direct DB queries on quantity columns.
59+
60+
### Service Objects
61+
Business logic lives in service classes (`app/services/`), not controllers. Pattern: `{Model}{Action}Service` (e.g., `DistributionCreateService`, `DonationDestroyService`). Controllers are thin and delegate to services.
62+
63+
### Key Models
64+
- **Item**: Individual item types (diapers, wipes, etc.) belonging to an Organization. Maps to a `BaseItem` (system-wide template) via `partner_key`.
65+
- **Kit**: A bundle of items. Kits contain line items referencing Items.
66+
- **StorageLocation**: Where inventory is physically stored. Inventory quantities are per storage location.
67+
- **Distribution**: Items sent to a Partner. **Donation/Purchase**: Items coming in. **Transfer**: Items between storage locations. **Adjustment**: Manual inventory corrections.
68+
- **Partner**: Organizations that receive distributions. Partners have their own portal (`/partners/*` routes) and users.
69+
- **Request**: Partner requests for items, which can become Distributions.
70+
71+
### Routes Structure
72+
- `/` - Bank user dashboard and resources (distributions, donations, etc.)
73+
- `/partners/*` - Partner-facing portal (separate namespace)
74+
- `/admin/*` - Super admin management
75+
- `/reports/*` - Reporting endpoints
76+
77+
### Query Objects
78+
Complex queries are extracted into `app/queries/` (e.g., `ItemsInQuery`, `LowInventoryQuery`).
79+
80+
### Frontend
81+
Bootstrap 5.2, Turbo Rails, Stimulus.js, ImportMap (no Webpack/bundler). JavaScript controllers live in `app/javascript/`.
82+
83+
### Background Jobs
84+
Delayed Job for async processing (emails, etc.). Clockwork (`clock.rb`) for scheduled tasks (caching historical data, reminder emails, DB backups).
85+
86+
### Feature Flags
87+
Flipper is available for feature flags, accessible at `/flipper` (auth required).
88+
89+
## Testing Conventions
90+
91+
- RSpec with FactoryBot. Factories are in `spec/factories/`.
92+
- **Setting up inventory in tests**: Use `TestInventory.create_inventory(organization, { storage_location_id => [[item_id, quantity], ...] })` from `spec/inventory.rb`. There's also a `setup_storage_location` helper in `spec/support/inventory_assistant.rb`.
93+
- System tests use Capybara with Cuprite driver. Failed screenshots go to `tmp/screenshots/` and `tmp/capybara/`.
94+
- Models use `has_paper_trail` for audit trails and `Discard` for soft deletes (not `destroy`).
95+
- The `Filterable` concern provides `class_filter` for scope-based filtering on index actions.
96+
97+
## Dev Credentials
98+
99+
All passwords are `password!`. Key accounts: `superadmin@example.com`, `org_admin1@example.com`, `user_1@example.com`.

Gemfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ end
1010
# User management and login workflow.
1111
gem "devise", '>= 4.7.1'
1212
# Postgres database adapter.
13-
gem "pg", "~> 1.6.2"
13+
gem "pg", "~> 1.6.3"
1414
# Web server.
1515
gem "puma"
1616
# Rails web framework.
@@ -158,7 +158,7 @@ group :development, :test do
158158
gem 'rubocop-performance'
159159
gem "rubocop-rails", "~> 2.33.4"
160160
# More concise test ("should") matchers
161-
gem "shoulda-matchers", "~> 6.5"
161+
gem "shoulda-matchers", "~> 7.0"
162162
# Default rules for Rubocop.
163163
gem "standard", "~> 1.52"
164164
gem "standard-rails"

Gemfile.lock

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ GEM
229229
dry-inflector (~> 1.0)
230230
dry-logic (~> 1.4)
231231
zeitwerk (~> 2.6)
232-
erb (6.0.1)
232+
erb (6.0.2)
233233
erb_lint (0.9.0)
234234
activesupport
235235
better_html (>= 2.0.1)
@@ -246,7 +246,7 @@ GEM
246246
railties (>= 6.1.0)
247247
faker (3.6.0)
248248
i18n (>= 1.8.11, < 2)
249-
faraday (1.10.4)
249+
faraday (1.10.5)
250250
faraday-em_http (~> 1.0)
251251
faraday-em_synchrony (~> 1.0)
252252
faraday-excon (~> 1.1)
@@ -259,10 +259,10 @@ GEM
259259
faraday-retry (~> 1.0)
260260
ruby2_keywords (>= 0.0.4)
261261
faraday-em_http (1.0.0)
262-
faraday-em_synchrony (1.0.0)
262+
faraday-em_synchrony (1.0.1)
263263
faraday-excon (1.1.0)
264264
faraday-httpclient (1.0.1)
265-
faraday-multipart (1.1.0)
265+
faraday-multipart (1.2.0)
266266
multipart-post (~> 2.0)
267267
faraday-net_http (1.0.2)
268268
faraday-net_http_persistent (1.2.0)
@@ -334,8 +334,9 @@ GEM
334334
activesupport (>= 6.0.0)
335335
railties (>= 6.0.0)
336336
io-console (0.8.2)
337-
irb (1.16.0)
337+
irb (1.17.0)
338338
pp (>= 0.6.0)
339+
prism (>= 1.3.0)
339340
rdoc (>= 4.0.0)
340341
reline (>= 0.4.2)
341342
jbuilder (2.14.1)
@@ -396,7 +397,8 @@ GEM
396397
memory_profiler (1.1.0)
397398
method_source (1.1.0)
398399
mini_mime (1.1.5)
399-
minitest (6.0.1)
400+
minitest (6.0.2)
401+
drb (~> 2.0)
400402
prism (~> 1.5)
401403
monetize (2.0.0)
402404
money (~> 7.0)
@@ -437,7 +439,7 @@ GEM
437439
notiffany (0.1.3)
438440
nenv (~> 0.1)
439441
shellany (~> 0.0)
440-
oauth2 (2.0.12)
442+
oauth2 (2.0.18)
441443
faraday (>= 0.17.3, < 4.0)
442444
jwt (>= 1.0, < 4.0)
443445
logger (~> 1.2)
@@ -450,13 +452,13 @@ GEM
450452
logger
451453
rack (>= 2.2.3)
452454
rack-protection
453-
omniauth-google-oauth2 (1.2.1)
455+
omniauth-google-oauth2 (1.2.2)
454456
jwt (>= 2.9.2)
455457
oauth2 (~> 2.0)
456458
omniauth (~> 2.0)
457459
omniauth-oauth2 (~> 1.8)
458-
omniauth-oauth2 (1.8.0)
459-
oauth2 (>= 1.4, < 3)
460+
omniauth-oauth2 (1.9.0)
461+
oauth2 (>= 2.0.2, < 3)
460462
omniauth (~> 2.0)
461463
omniauth-rails_csrf_protection (2.0.1)
462464
actionpack (>= 4.2)
@@ -480,9 +482,9 @@ GEM
480482
hashery (~> 2.0)
481483
ruby-rc4
482484
ttfunk
483-
pg (1.6.2-arm64-darwin)
484-
pg (1.6.2-x86_64-darwin)
485-
pg (1.6.2-x86_64-linux)
485+
pg (1.6.3-arm64-darwin)
486+
pg (1.6.3-x86_64-darwin)
487+
pg (1.6.3-x86_64-linux)
486488
popper_js (2.11.8)
487489
pp (0.6.3)
488490
prettyprint
@@ -562,8 +564,8 @@ GEM
562564
activesupport (>= 4.2)
563565
choice (~> 0.2.0)
564566
ruby-graphviz (~> 1.2)
565-
rails-html-sanitizer (1.6.2)
566-
loofah (~> 2.21)
567+
rails-html-sanitizer (1.7.0)
568+
loofah (~> 2.25)
567569
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
568570
railties (8.0.2.1)
569571
actionpack (= 8.0.2.1)
@@ -578,7 +580,7 @@ GEM
578580
rb-fsevent (0.11.2)
579581
rb-inotify (0.10.1)
580582
ffi (~> 1.0)
581-
rdoc (7.1.0)
583+
rdoc (7.2.0)
582584
erb
583585
psych (>= 4.0.0)
584586
tsort
@@ -659,8 +661,8 @@ GEM
659661
tilt
660662
securerandom (0.4.1)
661663
shellany (0.0.1)
662-
shoulda-matchers (6.5.0)
663-
activesupport (>= 5.2.0)
664+
shoulda-matchers (7.0.1)
665+
activesupport (>= 7.1)
664666
simple_form (5.4.0)
665667
actionpack (>= 7.0)
666668
activemodel (>= 7.0)
@@ -733,14 +735,13 @@ GEM
733735
uniform_notifier (1.18.0)
734736
uri (1.1.1)
735737
useragent (0.16.11)
736-
version_gem (1.1.8)
738+
version_gem (1.1.9)
737739
warden (1.2.9)
738740
rack (>= 2.0.9)
739-
web-console (4.2.1)
740-
actionview (>= 6.0.0)
741-
activemodel (>= 6.0.0)
741+
web-console (4.3.0)
742+
actionview (>= 8.0.0)
742743
bindex (>= 0.4.0)
743-
railties (>= 6.0.0)
744+
railties (>= 8.0.0)
744745
webmock (3.26.1)
745746
addressable (>= 2.8.0)
746747
crack (>= 0.3.2)
@@ -753,7 +754,7 @@ GEM
753754
xpath (3.2.0)
754755
nokogiri (~> 1.8)
755756
yard (0.9.37)
756-
zeitwerk (2.7.4)
757+
zeitwerk (2.7.5)
757758

758759
PLATFORMS
759760
arm64-darwin
@@ -819,7 +820,7 @@ DEPENDENCIES
819820
orderly (~> 0.1)
820821
paper_trail
821822
pdf-reader
822-
pg (~> 1.6.2)
823+
pg (~> 1.6.3)
823824
prawn (~> 2.4.0)
824825
prawn-rails
825826
pry-doc
@@ -838,7 +839,7 @@ DEPENDENCIES
838839
rubocop-performance
839840
rubocop-rails (~> 2.33.4)
840841
sass-rails
841-
shoulda-matchers (~> 6.5)
842+
shoulda-matchers (~> 7.0)
842843
simple_form
843844
simplecov
844845
solid_cache (~> 1.0)

app/controllers/items_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def index
1111
@items = @items.active unless params[:include_inactive_items]
1212

1313
@item_categories = current_organization.item_categories.includes(:items).order('name ASC')
14-
@kits = current_organization.kits.includes(line_items: :item)
14+
@kits = current_organization.kits.includes(item: {line_items: :item})
1515
@storages = current_organization.storage_locations.active.order(id: :asc)
1616

1717
@include_inactive_items = params[:include_inactive_items]

app/controllers/kits_controller.rb

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ def show
44
end
55

66
def index
7-
@kits = current_organization.kits.includes(:item, line_items: :item).class_filter(filter_params)
7+
@kits = current_organization.kits.includes(item: {line_items: :item}).class_filter(filter_params)
88
@inventory = View::Inventory.new(current_organization.id)
99
unless params[:include_inactive_items]
1010
@kits = @kits.active
@@ -16,7 +16,8 @@ def new
1616
load_form_collections
1717

1818
@kit = current_organization.kits.new
19-
@kit.line_items.build
19+
@kit.item = current_organization.items.new
20+
@kit.item.line_items.build
2021
end
2122

2223
def create
@@ -31,9 +32,12 @@ def create
3132
.map { |error| formatted_error_message(error) }
3233
.join(", ")
3334

34-
@kit = Kit.new(kit_params)
35+
# Extract kit and item params separately since line_items belong to Item, not Kit
36+
kit_only_params = kit_params.except(:line_items_attributes)
37+
@kit = Kit.new(kit_only_params)
3538
load_form_collections
36-
@kit.line_items.build if @kit.line_items.empty?
39+
@kit.item ||= current_organization.items.new(kit_params.slice(:line_items_attributes))
40+
@kit.item.line_items.build if @kit.item.line_items.empty?
3741

3842
render :new
3943
end
@@ -87,12 +91,14 @@ def load_form_collections
8791
end
8892

8993
def kit_params
90-
params.require(:kit).permit(
94+
kit_params = params.require(:kit).permit(
9195
:name,
9296
:visible_to_partners,
93-
:value_in_dollars,
94-
line_items_attributes: [:item_id, :quantity, :_destroy]
97+
:value_in_dollars
9598
)
99+
item_params = params.require(:item)
100+
.permit(line_items_attributes: [:item_id, :quantity, :_destroy])
101+
kit_params.to_h.merge(item_params.to_h)
96102
end
97103

98104
def kit_adjustment_params

app/events/kit_allocate_event.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
class KitAllocateEvent < Event
22
def self.event_line_items(kit, storage_location, quantity)
3-
items = kit.line_items.map do |item|
3+
items = kit.item.line_items.map do |item|
44
EventTypes::EventLineItem.new(
55
quantity: item.quantity * quantity,
66
item_id: item.item_id,

app/events/kit_deallocate_event.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
class KitDeallocateEvent < Event
22
def self.event_line_items(kit, storage_location, quantity)
3-
items = kit.line_items.map do |item|
3+
items = kit.item.line_items.map do |item|
44
EventTypes::EventLineItem.new(
55
quantity: item.quantity * quantity,
66
item_id: item.item_id,

0 commit comments

Comments
 (0)