Skip to content

Commit 93c9d0c

Browse files
committed
CR updates (thanks Claude for the spec fix)
1 parent 2e0da93 commit 93c9d0c

File tree

6 files changed

+125
-30
lines changed

6 files changed

+125
-30
lines changed

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

app/models/item.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def in_request?
105105

106106
def is_in_kit?(kits = nil)
107107
if kits
108-
kits.any? { |k| k.line_items.map(&:item_id).include?(id) }
108+
kits.any? { |k| k.item.line_items.map(&:item_id).include?(id) }
109109
else
110110
organization.kits
111111
.active

app/models/kit.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
#
1414
class Kit < ApplicationRecord
1515
has_paper_trail
16-
include Itemizable
1716
include Filterable
1817
include Valuable
1918

app/services/reports/adult_incontinence_report_service.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,13 @@ def distributed_adult_incontinence_items_from_kits
103103
FROM distributions
104104
INNER JOIN line_items ON line_items.itemizable_type = 'Distribution' AND line_items.itemizable_id = distributions.id
105105
INNER JOIN items ON items.id = line_items.item_id
106-
INNER JOIN kits ON kits.id = items.kit_id
107-
INNER JOIN line_items AS kit_line_items ON kits.id = kit_line_items.itemizable_id
106+
INNER JOIN line_items AS kit_line_items ON items.id = kit_line_items.itemizable_id
108107
INNER JOIN items AS kit_items ON kit_items.id = kit_line_items.item_id
109108
WHERE distributions.organization_id = ?
110109
AND EXTRACT(year FROM issued_at) = ?
111110
AND kit_items.reporting_category = 'adult_incontinence'
112-
AND kit_line_items.itemizable_type = 'Kit';
111+
AND items.kit_id IS NOT NULL
112+
AND kit_line_items.itemizable_type = 'Item';
113113
SQL
114114

115115
sanitized_sql = ActiveRecord::Base.send(:sanitize_sql_array, [sql_query, organization_id, year])
@@ -144,7 +144,7 @@ def distributed_kits_for_year
144144

145145
def total_distributed_kits_containing_adult_incontinence_items_per_month
146146
kits = Kit.where(id: distributed_kits_for_year).select do |kit|
147-
kit.items.adult_incontinence.exists?
147+
kit.item.items.adult_incontinence.exists?
148148
end
149149

150150
total_assisted_adults = kits.sum do |kit|

spec/services/reports/adult_incontinence_report_service_spec.rb

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,22 @@
4040
adult_incontinence_item = organization.items.adult_incontinence.first
4141
non_adult_incontinence_item = organization.items.where.not(id: organization.items.adult_incontinence).first
4242

43-
# kits
44-
adult_incontinence_kit_item_1 = create(:item, name: "Adult Briefs (Medium)", reporting_category: "adult_incontinence", distribution_quantity: 1)
45-
adult_incontinence_kit_item_2 = create(:item, name: "Adult Briefs (Large)", reporting_category: "adult_incontinence", distribution_quantity: 1)
46-
adult_incontinence_kit_item_3 = create(:item, name: "Adult Briefs (Small)", reporting_category: "adult_incontinence", distribution_quantity: 1)
47-
non_adult_incontinence_kit_item = create(:item, name: "Baby Wipes", reporting_category: "other")
48-
49-
donation_1 = create(:donation)
50-
donation_2 = create(:donation)
51-
donation_3 = create(:donation)
52-
donation_4 = create(:donation)
53-
54-
line_item_1 = LineItem.create!(item: adult_incontinence_kit_item_1, itemizable_id: donation_1.id, itemizable_type: "Donation", quantity: 5)
55-
line_item_2 = LineItem.create!(item: adult_incontinence_kit_item_2, itemizable_id: donation_2.id, itemizable_type: "Donation", quantity: 5)
56-
line_item_4 = LineItem.create!(item: adult_incontinence_kit_item_3, itemizable_id: donation_4.id, itemizable_type: "Donation", quantity: 5)
57-
line_item_3 = LineItem.create!(item: non_adult_incontinence_kit_item, itemizable_id: donation_3.id, itemizable_type: "Donation", quantity: 5)
58-
59-
@kit_1 = create(:kit, line_items: [line_item_1], organization: organization, item: adult_incontinence_kit_item_1)
60-
@kit_2 = create(:kit, line_items: [line_item_2], organization: organization, item: adult_incontinence_kit_item_2)
61-
@kit_4 = create(:kit, line_items: [line_item_4], organization: organization, item: adult_incontinence_kit_item_3)
62-
@kit_3 = create(:kit, line_items: [line_item_3], organization: organization, item: non_adult_incontinence_kit_item)
43+
# kit items (each item represents a kit and contains itself as a component)
44+
adult_incontinence_kit_item_1 = create(:item, name: "Adult Briefs (Medium)", reporting_category: "adult_incontinence", distribution_quantity: 1, organization: organization)
45+
adult_incontinence_kit_item_2 = create(:item, name: "Adult Briefs (Large)", reporting_category: "adult_incontinence", distribution_quantity: 1, organization: organization)
46+
adult_incontinence_kit_item_3 = create(:item, name: "Adult Briefs (Small)", reporting_category: "adult_incontinence", distribution_quantity: 1, organization: organization)
47+
non_adult_incontinence_kit_item = create(:item, name: "Baby Wipes", reporting_category: "other", organization: organization)
48+
49+
# Add component line items to each kit item
50+
adult_incontinence_kit_item_1.line_items.create!(item: adult_incontinence_kit_item_1, quantity: 5)
51+
adult_incontinence_kit_item_2.line_items.create!(item: adult_incontinence_kit_item_2, quantity: 5)
52+
adult_incontinence_kit_item_3.line_items.create!(item: adult_incontinence_kit_item_3, quantity: 5)
53+
non_adult_incontinence_kit_item.line_items.create!(item: non_adult_incontinence_kit_item, quantity: 5)
54+
55+
@kit_1 = create(:kit, organization: organization, item: adult_incontinence_kit_item_1)
56+
@kit_2 = create(:kit, organization: organization, item: adult_incontinence_kit_item_2)
57+
@kit_4 = create(:kit, organization: organization, item: adult_incontinence_kit_item_3)
58+
@kit_3 = create(:kit, organization: organization, item: non_adult_incontinence_kit_item)
6359

6460
# kit distributions
6561
kit_distribution_1 = create(:distribution, organization: organization, issued_at: within_time)
@@ -68,11 +64,11 @@
6864
# wipes distribution
6965
kit_distribution_3 = create(:distribution, organization: organization, issued_at: within_time)
7066

71-
create(:line_item, :distribution, quantity: 100, item: @kit_1.line_items.first.item, itemizable: kit_distribution_1)
72-
create(:line_item, :distribution, quantity: 100, item: @kit_2.line_items.first.item, itemizable: kit_distribution_2)
73-
create(:line_item, :distribution, quantity: 100, item: @kit_4.line_items.first.item, itemizable: kit_distribution_4)
67+
create(:line_item, :distribution, quantity: 100, item: @kit_1.item, itemizable: kit_distribution_1)
68+
create(:line_item, :distribution, quantity: 100, item: @kit_2.item, itemizable: kit_distribution_2)
69+
create(:line_item, :distribution, quantity: 100, item: @kit_4.item, itemizable: kit_distribution_4)
7470
# wipes kit no ai items
75-
create(:line_item, :distribution, quantity: 100, item: @kit_3.line_items.first.item, itemizable: kit_distribution_3)
71+
create(:line_item, :distribution, quantity: 100, item: @kit_3.item, itemizable: kit_distribution_3)
7672
# We will create data both within and outside our date range, and both adult_incontinence and non adult_incontinence.
7773
# Spec will ensure that only the required data is included.
7874

0 commit comments

Comments
 (0)