Skip to content

Commit adca842

Browse files
authored
Merge pull request #5426 from rubyforgood/3652-1-kits-to-items
3652 #1: kits to items
2 parents c0edf94 + aaaeddd commit adca842

26 files changed

+228
-133
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: `[email protected]`, `[email protected]`, `[email protected]`.

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,

app/models/item.rb

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class Item < ApplicationRecord
2727
include Filterable
2828
include Exportable
2929
include Valuable
30+
include Itemizable
3031

3132
after_initialize :set_default_distribution_quantity, if: :new_record?
3233
after_update :update_associated_kit_name, if: -> { kit.present? }
@@ -45,12 +46,13 @@ class Item < ApplicationRecord
4546
validates :on_hand_minimum_quantity, numericality: { greater_than_or_equal_to: 0 }
4647
validates :package_size, numericality: { greater_than_or_equal_to: 0 }, allow_blank: true
4748
validates :reporting_category, presence: true, unless: proc { |i| i.kit }
49+
validate -> { line_items_quantity_is_at_least(1) }
4850

49-
has_many :line_items, dependent: :destroy
51+
has_many :used_line_items, dependent: :destroy, class_name: "LineItem"
5052
has_many :inventory_items, dependent: :destroy
5153
has_many :barcode_items, as: :barcodeable, dependent: :destroy
52-
has_many :donations, through: :line_items, source: :itemizable, source_type: "::Donation"
53-
has_many :distributions, through: :line_items, source: :itemizable, source_type: "::Distribution"
54+
has_many :donations, through: :used_line_items, source: :itemizable, source_type: "::Donation"
55+
has_many :distributions, through: :used_line_items, source: :itemizable, source_type: "::Distribution"
5456
has_many :request_units, class_name: "ItemUnit", dependent: :destroy
5557

5658
scope :active, -> { where(active: true) }
@@ -103,17 +105,17 @@ def in_request?
103105

104106
def is_in_kit?(kits = nil)
105107
if kits
106-
kits.any? { |k| k.line_items.map(&:item_id).include?(id) }
108+
kits.any? { |k| k.item.line_items.map(&:item_id).include?(id) }
107109
else
108110
organization.kits
109111
.active
110-
.joins(:line_items)
112+
.joins(item: :line_items)
111113
.where(line_items: { item_id: id}).any?
112114
end
113115
end
114116

115117
def can_delete?(inventory = nil, kits = nil)
116-
can_deactivate_or_delete?(inventory, kits) && line_items.none? && !barcode_count&.positive? && !in_request? && kit.blank?
118+
can_deactivate_or_delete?(inventory, kits) && used_line_items.none? && !barcode_count&.positive? && !in_request? && kit.blank?
117119
end
118120

119121
# @return [Boolean]

app/models/kit.rb

Lines changed: 1 addition & 14 deletions
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

@@ -22,15 +21,11 @@ class Kit < ApplicationRecord
2221

2322
scope :active, -> { where(active: true) }
2423
scope :alphabetized, -> { order(:name) }
25-
scope :by_partner_key, ->(key) { joins(:items).where(items: { partner_key: key }) }
2624
scope :by_name, ->(name) { where("name ILIKE ?", "%#{name}%") }
2725

2826
validates :name, presence: true
2927
validates :name, uniqueness: { scope: :organization }
3028

31-
validate :at_least_one_item
32-
validate -> { line_items_quantity_is_at_least(1) }
33-
3429
# @param inventory [View::Inventory]
3530
# @return [Boolean]
3631
def can_deactivate?(inventory = nil)
@@ -47,19 +42,11 @@ def deactivate
4742
# or deallocated, we are changing inventory for inactive items (which we don't allow).
4843
# @return [Boolean]
4944
def can_reactivate?
50-
line_items.joins(:item).where(items: { active: false }).none?
45+
item.line_items.joins(:item).where(items: { active: false }).none?
5146
end
5247

5348
def reactivate
5449
update!(active: true)
5550
item.update!(active: true)
5651
end
57-
58-
private
59-
60-
def at_least_one_item
61-
unless line_items.any?
62-
errors.add(:base, 'At least one item is required')
63-
end
64-
end
6552
end

app/services/kit_create_service.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,13 @@ def call
2323

2424
organization.transaction do
2525
# Create the Kit record
26+
line_items = kit_params.delete(:line_items_attributes)
2627
@kit = Kit.new(kit_params_with_organization)
2728
@kit.save!
29+
if line_items.blank?
30+
@kit.errors.add(:base, 'At least one item is required')
31+
raise ActiveRecord::RecordInvalid.new(@kit)
32+
end
2833

2934
# Find or create the BaseItem for all items housing kits
3035
item_housing_a_kit_base_item = KitCreateService.find_or_create_kit_base_item!
@@ -33,6 +38,7 @@ def call
3338
item_creation = ItemCreateService.new(
3439
organization_id: organization.id,
3540
item_params: {
41+
line_items_attributes: line_items,
3642
name: kit.name,
3743
partner_key: item_housing_a_kit_base_item.partner_key,
3844
kit_id: kit.id
@@ -80,7 +86,9 @@ def valid?
8086
def kit_validation_errors
8187
return @kit_validation_errors if @kit_validation_errors
8288

83-
kit = Kit.new(kit_params_with_organization)
89+
# Exclude line_items_attributes as they belong to the Item, not the Kit
90+
kit_only_params = kit_params_with_organization.except(:line_items_attributes)
91+
kit = Kit.new(kit_only_params)
8492
kit.valid?
8593

8694
@kit_validation_errors = kit.errors

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|

0 commit comments

Comments
 (0)