Skip to content

Commit

Permalink
Merge pull request #3869 from rubyforgood/event-source
Browse files Browse the repository at this point in the history
Initial event sourcing implementation
  • Loading branch information
awwaiid authored Oct 15, 2023
2 parents bee691a + e5cb0a8 commit a0f48a9
Show file tree
Hide file tree
Showing 56 changed files with 1,180 additions and 56 deletions.
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ gem "paper_trail"
gem "rolify", "~> 6.0"
# Enforces "safe" migrations.
gem "strong_migrations", "1.6.3"
# used in events
gem 'dry-struct'

##### JAVSCRIPT/CSS/ASSETS #######

Expand Down
21 changes: 21 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,25 @@ GEM
dotenv-rails (2.8.1)
dotenv (= 2.8.1)
railties (>= 3.2)
dry-core (1.0.1)
concurrent-ruby (~> 1.0)
zeitwerk (~> 2.6)
dry-inflector (1.0.0)
dry-logic (1.5.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-struct (1.6.0)
dry-core (~> 1.0, < 2)
dry-types (>= 1.7, < 2)
ice_nine (~> 0.11)
zeitwerk (~> 2.6)
dry-types (1.7.1)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
erubi (1.12.0)
et-orbi (1.2.7)
tzinfo
Expand Down Expand Up @@ -246,6 +265,7 @@ GEM
icalendar (2.9.0)
ice_cube (~> 0.16)
ice_cube (0.16.4)
ice_nine (0.11.2)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
Expand Down Expand Up @@ -653,6 +673,7 @@ DEPENDENCIES
devise_invitable
discard (~> 1.3)
dotenv-rails
dry-struct
factory_bot_rails
faker
filterrific
Expand Down
1 change: 1 addition & 0 deletions app/controllers/audits_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def finalize
ActiveRecord::Base.transaction do
@audit.storage_location.increase_inventory increasing_adjustment
@audit.storage_location.decrease_inventory decreasing_adjustment
AuditEvent.publish(@audit)
end
@audit.finalized!
redirect_to audit_path(@audit), notice: "Audit is Finalized."
Expand Down
8 changes: 5 additions & 3 deletions app/controllers/donations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ def index
def create
@donation = current_organization.donations.new(donation_params)

if @donation.save
@donation.storage_location.increase_inventory @donation
if DonationCreateService.call(@donation)
flash[:notice] = "Donation created and logged!"
redirect_to donations_path
else
Expand Down Expand Up @@ -75,7 +74,10 @@ def show

def update
@donation = Donation.find(params[:id])
ItemizableUpdateService.call(itemizable: @donation, params: donation_params, type: :increase)
ItemizableUpdateService.call(itemizable: @donation,
params: donation_params,
type: :increase,
event_class: DonationEvent)
redirect_to donations_path
rescue => e
flash[:alert] = "Error updating donation: #{e.message}"
Expand Down
15 changes: 7 additions & 8 deletions app/controllers/purchases_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ def index

def create
@purchase = current_organization.purchases.new(purchase_params)
if @purchase.save
@purchase.storage_location.increase_inventory @purchase
if PurchaseCreateService.call(@purchase)
flash[:notice] = "New Purchase logged!"
redirect_to purchases_path
else
Expand Down Expand Up @@ -64,7 +63,10 @@ def show

def update
@purchase = current_organization.purchases.find(params[:id])
ItemizableUpdateService.call(itemizable: @purchase, params: purchase_params, type: :increase)
ItemizableUpdateService.call(itemizable: @purchase,
params: purchase_params,
type: :increase,
event_class: PurchaseEvent)
redirect_to purchases_path
rescue => e
load_form_collections
Expand All @@ -73,11 +75,8 @@ def update
end

def destroy
ActiveRecord::Base.transaction do
purchase = current_organization.purchases.find(params[:id])
purchase.storage_location.decrease_inventory(purchase)
purchase.destroy!
end
purchase = current_organization.purchases.find(params[:id])
PurchaseDestroyService.call(purchase)

flash[:notice] = "Purchase #{params[:id]} has been removed!"
redirect_to purchases_path
Expand Down
13 changes: 2 additions & 11 deletions app/controllers/transfers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,8 @@ def index
def create
@transfer = current_organization.transfers.new(transfer_params)

if @transfer.valid?
ActiveRecord::Base.transaction do
@transfer.save
@transfer.from.decrease_inventory @transfer
@transfer.to.increase_inventory @transfer
end

redirect_to transfers_path, notice: "#{@transfer.line_items.total} items have been transferred from #{@transfer.from.name} to #{@transfer.to.name}!"
else
raise StandardError.new(@transfer.errors.full_messages.join("</br>"))
end
TransferCreateService.call(@transfer)
redirect_to transfers_path, notice: "#{@transfer.line_items.total} items have been transferred from #{@transfer.from.name} to #{@transfer.to.name}!"
rescue StandardError => e
flash[:error] = e.message
load_form_collections
Expand Down
13 changes: 13 additions & 0 deletions app/events/adjustment_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class AdjustmentEvent < Event
# @param adjustment [Adjustment]
def self.publish(adjustment)
create(
eventable: adjustment,
organization_id: adjustment.organization_id,
event_time: Time.zone.now,
data: EventTypes::InventoryPayload.new(
items: EventTypes::EventLineItem.from_line_items(adjustment.line_items, to: adjustment.storage_location_id)
)
)
end
end
16 changes: 16 additions & 0 deletions app/events/audit_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class AuditEvent < Event
serialize :data, EventTypes::StructCoder.new(EventTypes::AuditPayload)

# @param audit [Audit]
def self.publish(audit)
create(
eventable: audit,
organization_id: audit.organization_id,
event_time: Time.zone.now,
data: EventTypes::AuditPayload.new(
storage_location_id: audit.storage_location_id,
items: EventTypes::EventLineItem.from_line_items(audit.line_items, to: audit.storage_location_id)
)
)
end
end
13 changes: 13 additions & 0 deletions app/events/distribution_destroy_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class DistributionDestroyEvent < Event
# @param distribution [Distribution]
def self.publish(distribution)
create(
eventable: distribution,
organization_id: distribution.organization_id,
event_time: Time.zone.now,
data: EventTypes::InventoryPayload.new(
items: EventTypes::EventLineItem.from_line_items(distribution.line_items, to: distribution.storage_location_id)
)
)
end
end
13 changes: 13 additions & 0 deletions app/events/distribution_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class DistributionEvent < Event
# @param distribution [Distribution]
def self.publish(distribution)
create(
eventable: distribution,
organization_id: distribution.organization_id,
event_time: Time.zone.now,
data: EventTypes::InventoryPayload.new(
items: EventTypes::EventLineItem.from_line_items(distribution.line_items, from: distribution.storage_location_id)
)
)
end
end
13 changes: 13 additions & 0 deletions app/events/donation_destroy_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class DonationDestroyEvent < Event
# @param donation [Donation]
def self.publish(donation)
create(
eventable: donation,
organization_id: donation.organization_id,
event_time: Time.zone.now,
data: EventTypes::InventoryPayload.new(
items: EventTypes::EventLineItem.from_line_items(donation.line_items, from: donation.storage_location_id)
)
)
end
end
13 changes: 13 additions & 0 deletions app/events/donation_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class DonationEvent < Event
# @param donation [Donation]
def self.publish(donation)
create(
eventable: donation,
organization_id: donation.organization_id,
event_time: Time.zone.now,
data: EventTypes::InventoryPayload.new(
items: EventTypes::EventLineItem.from_line_items(donation.line_items, to: donation.storage_location_id)
)
)
end
end
9 changes: 9 additions & 0 deletions app/events/event_types/audit_payload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module Types
include Dry.Types()
end

module EventTypes
class AuditPayload < InventoryPayload
attribute :storage_location_id, Types::Integer
end
end
11 changes: 11 additions & 0 deletions app/events/event_types/event_item.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Types
include Dry.Types()
end

module EventTypes
class EventItem < Dry::Struct
transform_keys(&:to_sym)
attribute :item_id, Types::Integer
attribute :quantity, Types::Integer
end
end
36 changes: 36 additions & 0 deletions app/events/event_types/event_line_item.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
module Types
include Dry.Types()
end

module EventTypes
class EventLineItem < Dry::Struct
transform_keys(&:to_sym)
attribute :quantity, Types::Integer # can be positive or negative
attribute :item_id, Types::Integer
attribute :item_value_in_cents, Types::Integer
attribute :from_storage_location, Types::Integer.optional
attribute :to_storage_location, Types::Integer.optional

# @param line_item [LineItem]
# @param from [Integer]
# @param to [Integer]
# @return [EventLineItem]
def self.from_line_item(line_item, from: nil, to: nil)
new(
quantity: line_item.quantity,
item_id: line_item.item_id,
item_value_in_cents: line_item.item.value_in_cents,
from_storage_location: from,
to_storage_location: to
)
end

# @param line_item [Array<LineItem>]
# @param from [Integer]
# @param to [Integer]
# @return [Array<EventLineItem>]
def self.from_line_items(line_items, from: nil, to: nil)
line_items.map { |i| from_line_item(i, from: from, to: to) }
end
end
end
51 changes: 51 additions & 0 deletions app/events/event_types/event_storage_location.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
module Types
include Dry.Types()
end

module EventTypes
class EventStorageLocation < Dry::Struct
transform_keys(&:to_sym)

attribute :id, Types::Integer
attribute :items, Types::Hash.map(Types::Coercible::Integer, EventTypes::EventItem)

# @param storage_location [StorageLocation]
# @return [EventTypes::EventStorageLocation]
def self.from(storage_location)
new(id: storage_location.id, items: {})
end

def reset!
items.clear
end

# @param item_id [Integer]
# @param quantity [Integer]
def set_inventory(item_id, quantity)
items[item_id] = EventTypes::EventItem.new(item_id: item_id, quantity: quantity)
end

# @param item_id [Integer]
# @param quantity [Integer]
# @param validate [Boolean]
def reduce_inventory(item_id, quantity, validate: true)
if validate
if items[item_id].nil?
raise "Item #{item_id} not found in storage location #{id}"
end
if items[item_id].quantity < quantity
raise "Could not reduce quantity by #{quantity} for item #{item_id} in storage location #{id} - current quantity is #{items[item_id].quantity}"
end
end
current_quantity = items[item_id]&.quantity || 0
items[item_id] = EventTypes::EventItem.new(item_id: item_id, quantity: current_quantity - quantity)
end

# @param item_id [Integer]
# @param quantity [Integer]
def add_inventory(item_id, quantity)
current_quantity = items[item_id]&.quantity || 0
items[item_id] = EventTypes::EventItem.new(item_id: item_id, quantity: current_quantity + quantity)
end
end
end
32 changes: 32 additions & 0 deletions app/events/event_types/inventory.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module Types
include Dry.Types()
end

module EventTypes
class Inventory < Dry::Struct
transform_keys(&:to_sym)
attribute :organization_id, Types::Integer
attribute :storage_locations, Types::Hash.map(Types::Coercible::Integer, EventTypes::EventStorageLocation)

# @param organization_id [Integer]
# @return [EventTypes::Inventory]
def self.from(organization_id)
org = Organization.find(organization_id)
new(organization_id: organization_id,
storage_locations: org.storage_locations.map { |s| [s.id, EventTypes::EventStorageLocation.from(s)] }.to_h)
end

def move_item(item_id:, quantity:, from_location: nil, to_location: nil, validate: true)
if from_location
if storage_locations[from_location].nil? && validate
raise "Storage location #{from_location} not found!"
end
storage_locations[from_location] ||= EventTypes::EventStorageLocation.new(id: from_location, items: {})
storage_locations[from_location].reduce_inventory(item_id, quantity, validate: validate)
end
if to_location
storage_locations[to_location].add_inventory(item_id, quantity)
end
end
end
end
11 changes: 11 additions & 0 deletions app/events/event_types/inventory_payload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Types
include Dry.Types()
end

module EventTypes
class InventoryPayload < Dry::Struct
transform_keys(&:to_sym)

attribute :items, Types::Array.of(EventTypes::EventLineItem)
end
end
19 changes: 19 additions & 0 deletions app/events/event_types/struct_coder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# see https://discourse.dry-rb.org/t/dry-struct-and-serializer/1517/2
module EventTypes
class StructCoder
attr_reader :struct

def initialize(struct)
@struct = struct
end

def dump(s)
s.to_hash
end

def load(h)
return if h.nil?
struct.new(h.with_indifferent_access)
end
end
end
Loading

0 comments on commit a0f48a9

Please sign in to comment.