Skip to content

Commit 5b13f98

Browse files
committed
feat(products): add product item filter API endpoints
Add /api/v1/product_items/:product_item_id/filters CRUD endpoints backed by the filter services: create with the key/value pairs, update replacing the value set wholesale (code is immutable and not permitted), show, paginated index scoped to the parent item, and soft delete.
1 parent 7671b5f commit 5b13f98

5 files changed

Lines changed: 265 additions & 9 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
module V1
5+
module ProductItems
6+
class FiltersController < Api::BaseController
7+
before_action :find_product_item
8+
before_action :find_product_item_filter, only: %i[show update destroy]
9+
10+
def index
11+
filters = product_item.filters
12+
.includes(values: :billable_metric_filter)
13+
.page(params[:page])
14+
.per(params[:per_page] || PER_PAGE)
15+
16+
render(
17+
json: ::CollectionSerializer.new(
18+
filters,
19+
::V1::ProductItemFilterSerializer,
20+
collection_name: "filters",
21+
meta: pagination_metadata(filters)
22+
)
23+
)
24+
end
25+
26+
def show
27+
render_filter(product_item_filter)
28+
end
29+
30+
def create
31+
result = ::ProductItemFilters::CreateService.call(
32+
product_item:,
33+
params: input_params.to_h.deep_symbolize_keys
34+
)
35+
36+
if result.success?
37+
render_filter(result.product_item_filter)
38+
else
39+
render_error_response(result)
40+
end
41+
end
42+
43+
def update
44+
result = ::ProductItemFilters::UpdateService.call(
45+
product_item_filter:,
46+
params: update_params.to_h.deep_symbolize_keys
47+
)
48+
49+
if result.success?
50+
render_filter(result.product_item_filter)
51+
else
52+
render_error_response(result)
53+
end
54+
end
55+
56+
def destroy
57+
result = ::ProductItemFilters::DestroyService.call(product_item_filter:)
58+
59+
if result.success?
60+
render_filter(result.product_item_filter)
61+
else
62+
render_error_response(result)
63+
end
64+
end
65+
66+
private
67+
68+
attr_reader :product_item, :product_item_filter
69+
70+
def find_product_item
71+
@product_item = current_organization.product_items.find_by(id: params[:product_item_id])
72+
73+
not_found_error(resource: "product_item") unless product_item
74+
end
75+
76+
def find_product_item_filter
77+
@product_item_filter = product_item.filters.find_by(id: params[:id])
78+
79+
not_found_error(resource: "product_item_filter") unless product_item_filter
80+
end
81+
82+
def input_params
83+
params.require(:filter).permit(
84+
:name,
85+
:code,
86+
:description,
87+
:invoice_display_name,
88+
values: %i[billable_metric_filter_id value]
89+
)
90+
end
91+
92+
def update_params
93+
params.require(:filter).permit(
94+
:name,
95+
:description,
96+
:invoice_display_name,
97+
values: %i[billable_metric_filter_id value]
98+
)
99+
end
100+
101+
def render_filter(filter)
102+
render(json: ::V1::ProductItemFilterSerializer.new(filter, root_name: "filter"))
103+
end
104+
105+
def resource_name
106+
"product_item"
107+
end
108+
end
109+
end
110+
end
111+
end

app/controllers/api/v1/product_items_controller.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module Api
44
module V1
55
class ProductItemsController < Api::BaseController
66
def create
7-
result = ProductItems::CreateService.call(
7+
result = ::ProductItems::CreateService.call(
88
organization: current_organization,
99
params: input_params.to_h.symbolize_keys
1010
)
@@ -18,7 +18,7 @@ def create
1818

1919
def update
2020
product_item = current_organization.product_items.find_by(id: params[:id])
21-
result = ProductItems::UpdateService.call(product_item:, params: update_params.to_h.symbolize_keys)
21+
result = ::ProductItems::UpdateService.call(product_item:, params: update_params.to_h.symbolize_keys)
2222

2323
if result.success?
2424
render_product_item(result.product_item)
@@ -29,7 +29,7 @@ def update
2929

3030
def destroy
3131
product_item = current_organization.product_items.find_by(id: params[:id])
32-
result = ProductItems::DestroyService.call(product_item:)
32+
result = ::ProductItems::DestroyService.call(product_item:)
3333

3434
if result.success?
3535
render_product_item(result.product_item)
@@ -47,7 +47,7 @@ def show
4747
end
4848

4949
def index
50-
result = ProductItemsQuery.call(
50+
result = ::ProductItemsQuery.call(
5151
organization: current_organization,
5252
pagination: {
5353
page: params[:page],

app/controllers/api/v1/products_controller.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module Api
44
module V1
55
class ProductsController < Api::BaseController
66
def create
7-
result = Products::CreateService.call(
7+
result = ::Products::CreateService.call(
88
organization: current_organization,
99
params: input_params.to_h.symbolize_keys
1010
)
@@ -18,7 +18,7 @@ def create
1818

1919
def update
2020
product = current_organization.products.find_by(code: params[:code])
21-
result = Products::UpdateService.call(product:, params: update_params.to_h.symbolize_keys)
21+
result = ::Products::UpdateService.call(product:, params: update_params.to_h.symbolize_keys)
2222

2323
if result.success?
2424
render_product(result.product)
@@ -29,7 +29,7 @@ def update
2929

3030
def destroy
3131
product = current_organization.products.find_by(code: params[:code])
32-
result = Products::DestroyService.call(product:)
32+
result = ::Products::DestroyService.call(product:)
3333

3434
if result.success?
3535
render_product(result.product)
@@ -47,7 +47,7 @@ def show
4747
end
4848

4949
def index
50-
result = ProductsQuery.call(
50+
result = ::ProductsQuery.call(
5151
organization: current_organization,
5252
pagination: {
5353
page: params[:page],

config/routes.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@
165165
end
166166
end
167167
end
168-
resources :product_items, only: %i[index show create update destroy]
168+
resources :product_items, only: %i[index show create update destroy] do
169+
resources :filters, only: %i[index show create update destroy], controller: "product_items/filters"
170+
end
169171
resources :products, param: :code, code: /.*/, only: %i[index show create update destroy]
170172
resources :taxes, param: :code, code: /.*/
171173
resources :wallet_transactions, only: %i[create show] do
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe Api::V1::ProductItems::FiltersController do
6+
let(:organization) { create(:organization) }
7+
let(:billable_metric) { create(:billable_metric, organization:) }
8+
let(:product_item) { create(:product_item, organization:, billable_metric:) }
9+
let(:region_filter) { create(:billable_metric_filter, organization:, billable_metric:, key: "region", values: %w[us eu]) }
10+
11+
describe "POST /api/v1/product_items/:product_item_id/filters" do
12+
subject { post_with_token(organization, "/api/v1/product_items/#{product_item.id}/filters", {filter: create_params}) }
13+
14+
let(:create_params) do
15+
{
16+
name: "US",
17+
code: "us",
18+
values: [{billable_metric_filter_id: region_filter.id, value: "us"}]
19+
}
20+
end
21+
22+
include_examples "requires API permission", "product_item", "write"
23+
24+
it "creates a filter with its values" do
25+
subject
26+
27+
expect(response).to have_http_status(:success)
28+
expect(json[:filter][:lago_id]).to be_present
29+
expect(json[:filter][:name]).to eq("US")
30+
expect(json[:filter][:code]).to eq("us")
31+
expect(json[:filter][:values].map { [it[:key], it[:value]] }).to eq([%w[region us]])
32+
end
33+
34+
context "when values are missing" do
35+
let(:create_params) { {name: "US", code: "us", values: []} }
36+
37+
it "returns a validation error" do
38+
subject
39+
40+
expect(response).to have_http_status(:unprocessable_entity)
41+
end
42+
end
43+
44+
context "when the product item does not exist" do
45+
subject { post_with_token(organization, "/api/v1/product_items/#{SecureRandom.uuid}/filters", {filter: create_params}) }
46+
47+
it "returns a not found error" do
48+
subject
49+
50+
expect(response).to be_not_found_error("product_item")
51+
end
52+
end
53+
end
54+
55+
describe "PUT /api/v1/product_items/:product_item_id/filters/:id" do
56+
subject do
57+
put_with_token(
58+
organization,
59+
"/api/v1/product_items/#{product_item.id}/filters/#{filter.id}",
60+
{filter: update_params}
61+
)
62+
end
63+
64+
let(:filter) do
65+
record = create(:product_item_filter, organization:, product_item:, name: "Before")
66+
create(:product_item_filter_value, organization:, product_item_filter: record, billable_metric_filter: region_filter, value: "us")
67+
record
68+
end
69+
70+
let(:update_params) { {name: "After", values: [{billable_metric_filter_id: region_filter.id, value: "eu"}]} }
71+
72+
include_examples "requires API permission", "product_item", "write"
73+
74+
it "updates the filter and replaces its values" do
75+
subject
76+
77+
expect(response).to have_http_status(:success)
78+
expect(json[:filter][:name]).to eq("After")
79+
expect(json[:filter][:values].map { [it[:key], it[:value]] }).to eq([%w[region eu]])
80+
end
81+
82+
context "when the filter does not exist" do
83+
subject { put_with_token(organization, "/api/v1/product_items/#{product_item.id}/filters/#{SecureRandom.uuid}", {filter: update_params}) }
84+
85+
it "returns a not found error" do
86+
subject
87+
88+
expect(response).to be_not_found_error("product_item_filter")
89+
end
90+
end
91+
end
92+
93+
describe "GET /api/v1/product_items/:product_item_id/filters/:id" do
94+
subject { get_with_token(organization, "/api/v1/product_items/#{product_item.id}/filters/#{filter.id}") }
95+
96+
let(:filter) { create(:product_item_filter, :with_values, organization:, product_item:) }
97+
98+
include_examples "requires API permission", "product_item", "read"
99+
100+
it "returns the filter" do
101+
subject
102+
103+
expect(response).to have_http_status(:success)
104+
expect(json[:filter][:lago_id]).to eq(filter.id)
105+
expect(json[:filter][:values].count).to eq(1)
106+
end
107+
end
108+
109+
describe "GET /api/v1/product_items/:product_item_id/filters" do
110+
subject { get_with_token(organization, "/api/v1/product_items/#{product_item.id}/filters") }
111+
112+
before do
113+
create(:product_item_filter, organization:, product_item:)
114+
create(:product_item_filter, organization:)
115+
end
116+
117+
include_examples "requires API permission", "product_item", "read"
118+
119+
it "returns only the filters of the product item" do
120+
subject
121+
122+
expect(response).to have_http_status(:success)
123+
expect(json[:filters].count).to eq(1)
124+
expect(json[:meta][:total_count]).to eq(1)
125+
end
126+
end
127+
128+
describe "DELETE /api/v1/product_items/:product_item_id/filters/:id" do
129+
subject { delete_with_token(organization, "/api/v1/product_items/#{product_item.id}/filters/#{filter.id}") }
130+
131+
let(:filter) { create(:product_item_filter, :with_values, organization:, product_item:) }
132+
133+
include_examples "requires API permission", "product_item", "write"
134+
135+
it "soft deletes the filter" do
136+
subject
137+
138+
expect(response).to have_http_status(:success)
139+
expect(json[:filter][:lago_id]).to eq(filter.id)
140+
expect(filter.reload).to be_discarded
141+
end
142+
end
143+
end

0 commit comments

Comments
 (0)