init commit
Some checks failed
Build and Publish / build-release (push) Failing after 1s

This commit is contained in:
2026-04-15 16:20:22 -05:00
parent 072dbf6e66
commit b1b1c21e4e
28 changed files with 822 additions and 194 deletions

View File

@@ -0,0 +1,51 @@
defmodule CustomerService.Aggregates.CorporateCustomer do
defstruct [
:id,
:legal_name,
:commercial_name,
:ruc,
:legal_rep_name,
:legal_rep_document_id,
:email,
:phone,
:address
]
alias __MODULE__
alias Commanded.Aggregates.Aggregate
alias CustomerService.Commands
alias CustomerService.Events
@behaviour Aggregate
@impl Aggregate
def execute(%CorporateCustomer{id: nil}, %Commands.CreateCorporateCustomer{} = cmd) do
%Events.CorporateCustomerCreated{
id: cmd.id,
legal_name: cmd.legal_name,
commercial_name: cmd.commercial_name,
ruc: cmd.ruc,
legal_rep_name: cmd.legal_rep_name,
legal_rep_document_id: cmd.legal_rep_document_id,
email: cmd.email,
phone: cmd.phone,
address: cmd.address
}
end
@impl Aggregate
def apply(%CorporateCustomer{} = c, %Events.CorporateCustomerCreated{} = e) do
%CorporateCustomer{
c
| id: e.id,
legal_name: e.legal_name,
commercial_name: e.commercial_name,
ruc: e.ruc,
legal_rep_name: e.legal_rep_name,
legal_rep_document_id: e.legal_rep_document_id,
email: e.email,
phone: e.phone,
address: e.address
}
end
end

View File

@@ -6,7 +6,9 @@ defmodule CustomerService.Aggregates.Customer do
:birth_date,
:gender,
:email,
:phone
:phone,
:address,
:document_id
]
alias __MODULE__
@@ -24,13 +26,15 @@ defmodule CustomerService.Aggregates.Customer do
birth_date: cmd.birth_date,
gender: cmd.gender,
email: cmd.email,
phone: cmd.phone
phone: cmd.phone,
document_id: cmd.document_id,
address: cmd.address
}
end
@impl Aggregate
def apply(%Customer{} = c, %Events.CustomerCreated{} = e) do
%Customer{
%__MODULE__{
c
| id: e.id,
first_name: e.first_name,
@@ -38,7 +42,9 @@ defmodule CustomerService.Aggregates.Customer do
birth_date: e.birth_date,
gender: e.gender,
email: e.email,
phone: e.phone
phone: e.phone,
address: e.address,
document_id: e.document_id
}
end
end

View File

@@ -5,6 +5,11 @@ defmodule CustomerService.Router do
identify(Aggregates.Customer, by: :id)
dispatch([Commands.CreateCustomer], to: Aggregates.Customer)
dispatch(Commands.CreateCorporateCustomer,
to: Aggregates.CorporateCustomer,
identity: :id
)
end
defmodule CustomerService.CommandedApp do

View File

@@ -6,6 +6,22 @@ defmodule CustomerService.Commands.CreateCustomer do
:birth_date,
:gender,
:email,
:phone
:phone,
:address,
:document_id
]
end
defmodule CustomerService.Commands.CreateCorporateCustomer do
defstruct [
:id,
:legal_name,
:commercial_name,
:ruc,
:legal_rep_name,
:legal_rep_document_id,
:email,
:phone,
:address
]
end

View File

@@ -0,0 +1,19 @@
defmodule CustomerService.Customers.Filters do
import Ecto.Query
def search(query, %Flop.Filter{value: value}, _opts) do
term = "%#{value}%"
where(
query,
[c],
ilike(c.first_name, ^term) or
ilike(c.last_name, ^term) or
ilike(c.legal_name, ^term) or
ilike(c.email, ^term) or
ilike(c.phone, ^term) or
ilike(c.document_id, ^term) or
ilike(c.ruc, ^term)
)
end
end

View File

@@ -0,0 +1,15 @@
defmodule CustomerService.Customer.Queries do
alias CustomerService.Projections.Customer
alias CustomerService.Repo
def list_customers(params \\ %{}) do
Flop.validate_and_run(Customer, params, for: Customer)
end
def get_customer(id) do
case Repo.get(Customer, id) do
nil -> {:error, :not_found}
customer -> {:ok, customer}
end
end
end

View File

@@ -7,6 +7,23 @@ defmodule CustomerService.Events.CustomerCreated do
:birth_date,
:gender,
:email,
:phone
:phone,
:address,
:document_id
]
end
defmodule CustomerService.Events.CorporateCustomerCreated do
@derive Jason.Encoder
defstruct [
:id,
:legal_name,
:commercial_name,
:ruc,
:legal_rep_name,
:legal_rep_document_id,
:email,
:phone,
:address
]
end

View File

@@ -2,24 +2,66 @@ defmodule CustomerService.Projections.Customer do
use Ecto.Schema
@derive {Jason.Encoder,
only: [
:id,
:first_name,
:last_name,
:birth_date,
:gender,
:email,
:phone,
:inserted_at,
:updated_at
]}
only: [
:id,
:customer_type,
# individual
:first_name,
:last_name,
:birth_date,
:gender,
:document_id,
# corporate
:legal_name,
:commercial_name,
:ruc,
:legal_rep_name,
:legal_rep_document_id,
# shared
:address,
:email,
:phone,
:inserted_at,
:updated_at
]}
@derive {
Flop.Schema,
filterable: [:customer_type, :email, :phone, :document_id, :ruc, :search],
sortable: [:last_name, :legal_name, :inserted_at],
default_limit: 20,
max_limit: 100,
custom_fields: [
search: [
filter: {CustomerService.Customers.Filters, :search, []},
ecto_type: :string,
operators: [:==]
]
]
}
@primary_key {:id, :binary_id, autogenerate: false}
@timestamps_opts [type: :utc_datetime_usec]
schema "customers" do
field :customer_type, :string, default: "individual"
# individual fields
field :first_name, :string
field :last_name, :string
field :birth_date, :date
field :gender, :string
field :document_id, :string
# corporate fields
field :legal_name, :string
field :commercial_name, :string
field :ruc, :string
field :legal_rep_name, :string
field :legal_rep_document_id, :string
# shared
field :address, :string
field :email, :string
field :phone, :string

View File

@@ -11,21 +11,40 @@ defmodule CustomerService.Projectors.Customer do
project(%Events.CustomerCreated{} = event, fn multi ->
Ecto.Multi.insert(multi, :customer, %Customer{
id: event.id,
customer_type: "individual",
first_name: event.first_name,
last_name: event.last_name,
birth_date: event.birth_date,
birth_date: parse_date(event.birth_date),
gender: event.gender,
email: event.email,
phone: event.phone
phone: event.phone,
address: event.address,
document_id: event.document_id
})
end)
# project %Events.CustomerDeactivated{} = event, _metadata do
# Ecto.Multi.update_all(
# multi,
# :deactivate_customer,
# from(c in Customer, where: c.customer_id == ^event.customer_id),
# set: [active: false]
# )
# end
defp parse_date(nil), do: nil
defp parse_date(%Date{} = d), do: d
defp parse_date(str) when is_binary(str) do
case Date.from_iso8601(str) do
{:ok, d} -> d
_ -> nil
end
end
project(%Events.CorporateCustomerCreated{} = e, _meta, fn multi ->
Ecto.Multi.insert(multi, :customer, %Customer{
id: e.id,
customer_type: "corporate",
legal_name: e.legal_name,
commercial_name: e.commercial_name,
ruc: e.ruc,
legal_rep_name: e.legal_rep_name,
legal_rep_document_id: e.legal_rep_document_id,
email: e.email,
phone: e.phone,
address: e.address
})
end)
end

View File

@@ -1,84 +1,181 @@
defmodule CustomerServiceWeb.Customer do
defmodule CustomerServiceWeb.CustomerController do
use CustomerServiceWeb, :controller
alias CustomerServiceWeb.Schemas.CreateCustomerRequest
alias CustomerServiceWeb.Schemas.CustomerResponse
alias CustomerService.Commands.CreateCustomer
alias CustomerService.CommandedApp
use OpenApiSpex.ControllerSpecs
tags ["Customers"]
alias CustomerService.Commands.{CreateCustomer, CreateCorporateCustomer}
alias CustomerService.Customer.Queries, as: CustomerQueries
alias CustomerServiceWeb.Schemas.Customer, as: CustomerSchemas
operation :create,
summary: "Create customer",
request_body: {"Customer data", "application/json", CreateCustomerRequest},
operation(:index,
summary: "List customers",
parameters: [
"page[number]": [in: :query, type: :integer, required: false],
"page[size]": [in: :query, type: :integer, required: false],
"filters[0][field]": [in: :query, type: :string, required: false],
"filters[0][op]": [in: :query, type: :string, required: false],
"filters[0][value]": [in: :query, type: :string, required: false]
],
responses: [
ok: {"Customer created", "application/json", CustomerResponse}
ok: {"Customer list", "application/json", CustomerSchemas.CustomerListResponse}
]
)
def index(conn, params) do
case CustomerQueries.list_customers(params) do
{:ok, {customers, meta}} ->
conn
|> put_status(:ok)
|> json(%{
data: Enum.map(customers, &customer_json/1),
meta: %{
total_count: meta.total_count,
total_pages: meta.total_pages,
current_page: meta.current_page,
page_size: meta.page_size,
has_next: meta.has_next_page?,
has_prev: meta.has_previous_page?
}
})
{:error, _meta} ->
conn
|> put_status(:bad_request)
|> json(%{error: "invalid parameters"})
end
end
operation(:show,
summary: "Get customer",
parameters: [
id: [in: :path, type: :string, required: true, description: "Customer ID"]
],
responses: [
ok: {"Customer", "application/json", CustomerSchemas.CustomerResponse},
not_found: {"Not found", "application/json", %OpenApiSpex.Schema{type: :object}}
]
)
def show(conn, %{"id" => id}) do
case CustomerQueries.get_customer(id) do
{:ok, customer} ->
conn |> put_status(:ok) |> json(%{data: customer_json(customer)})
{:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "not found"})
end
end
operation(:create,
summary: "Create individual customer",
request_body:
{"Customer data", "application/json", CustomerSchemas.CreateCustomer, required: true},
responses: [
ok: {"Customer created", "application/json", CustomerSchemas.CustomerResponse}
]
)
def create(conn, params) do
customer_id = Ecto.UUID.generate()
command =
%CreateCustomer{
id: customer_id,
first_name: params["first_name"],
last_name: params["last_name"],
birth_date: Date.from_iso8601!(params["birth_date"]),
gender: params["gender"],
email: params["email"],
phone: params["phone"]
}
command = %CreateCustomer{
id: customer_id,
first_name: params["first_name"],
last_name: params["last_name"],
birth_date: parse_date(params["birth_date"]),
gender: params["gender"],
email: params["email"],
phone: params["phone"],
document_id: params["document_id"]
}
case CommandedApp.dispatch(command, consistency: :strong) do
dispatch_and_return(conn, command, customer_id)
end
operation(:create_corporate,
summary: "Create corporate customer",
request_body:
{"Corporate customer data", "application/json", CustomerSchemas.CreateCorporateCustomer,
required: true},
responses: [
ok: {"Corporate customer created", "application/json", CustomerSchemas.CustomerResponse}
]
)
def create_corporate(conn, params) do
customer_id = Ecto.UUID.generate()
command = %CreateCorporateCustomer{
id: customer_id,
legal_name: params["legal_name"],
commercial_name: params["commercial_name"],
ruc: params["ruc"],
legal_rep_name: params["legal_rep_name"],
legal_rep_document_id: params["legal_rep_document_id"],
email: params["email"],
phone: params["phone"],
address: params["address"]
}
dispatch_and_return(conn, command, customer_id)
end
# ---------------------------------------------------------------------------
# Private
# ---------------------------------------------------------------------------
defp dispatch_and_return(conn, command, customer_id) do
case CustomerService.CommandedApp.dispatch(command, consistency: :strong) do
:ok ->
json(conn, %{id: customer_id})
case CustomerQueries.get_customer(customer_id) do
{:ok, customer} ->
conn |> put_status(:ok) |> json(%{data: customer_json(customer)})
{:error, :not_found} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "customer created but not found in projection"})
end
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: inspect(reason)})
conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)})
end
end
operation :show,
summary: "Get customer",
parameters: [
id: [in: :path, type: :string, description: "Customer ID"]
],
responses: [
ok: {"Customer", "application/json", CustomerResponse},
not_found: {"Not found", "application/json", nil}
]
defp parse_date(nil), do: nil
def show(conn, %{"id" => id}) do
case CustomerService.Repo.get(CustomerService.Projections.Customer, id) do
nil ->
send_resp(conn, 404, "")
customer ->
json(conn, customer)
defp parse_date(str) do
case Date.from_iso8601(str) do
{:ok, date} -> date
_ -> nil
end
end
operation :index,
summary: "List customers",
responses: [
ok:
{"Customer list", "application/json",
%OpenApiSpex.Schema{
type: :array,
items: CustomerResponse
}}
]
defp customer_json(%{customer_type: "corporate"} = c) do
%{
id: c.id,
customer_type: "corporate",
legal_name: c.legal_name,
commercial_name: c.commercial_name,
ruc: c.ruc,
legal_rep_name: c.legal_rep_name,
legal_rep_document_id: c.legal_rep_document_id,
email: c.email,
phone: c.phone,
address: c.address
}
end
def index(conn, _) do
case CustomerService.Repo.all(CustomerService.Projections.Customer) do
nil ->
send_resp(conn, 404, "")
customer ->
json(conn, customer)
end
defp customer_json(c) do
%{
id: c.id,
customer_type: "individual",
first_name: c.first_name,
last_name: c.last_name,
email: c.email,
phone: c.phone,
birth_date: c.birth_date,
gender: c.gender,
document_id: c.document_id
}
end
end

View File

@@ -1,20 +1,16 @@
defmodule CustomerServiceWeb.ErrorJSON do
@moduledoc """
This module is invoked by your endpoint in case of errors on JSON requests.
See config/config.exs.
"""
# If you want to customize a particular status code,
# you may add your own clauses, such as:
#
# def render("500.json", _assigns) do
# %{errors: %{detail: "Internal Server Error"}}
# end
def render("404.json", _assigns) do
%{errors: %{detail: "Not Found"}}
end
def render("500.json", _assigns) do
%{errors: %{detail: "Internal Server Error"}}
end
# By default, Phoenix returns the status message from
# the template name. For example, "404.json" becomes
# "Not Found".
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end

View File

@@ -0,0 +1,15 @@
defmodule CustomerServiceWeb.HealthController do
use CustomerServiceWeb, :controller
def health(conn, _params) do
conn
|> put_status(:ok)
|> json(%{status: "ok"})
end
def ready(conn, _params) do
conn
|> put_status(:ok)
|> json(%{status: "ready"})
end
end

View File

@@ -45,5 +45,6 @@ defmodule CustomerServiceWeb.Endpoint do
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug CORSPlug, origin: ["http://localhost:3000"]
plug CustomerServiceWeb.Router
end

View File

@@ -1,15 +1,32 @@
defmodule CustomerServiceWeb.Router do
use CustomerServiceWeb, :router
alias CustomerServiceWeb.CustomerController
pipeline :api do
plug CORSPlug, origin: "*"
plug :accepts, ["json"]
plug OpenApiSpex.Plug.PutApiSpec, module: CustomerServiceWeb.ApiSpec
end
get("/health", CustomerServiceWeb.HealthController, :health)
get("/health/ready", CustomerServiceWeb.HealthController, :ready)
scope "/api" do
pipe_through :api
resources "/customers", CustomerServiceWeb.Customer, only: [:create, :index, :show]
get "/openapi", OpenApiSpex.Plug.RenderSpec, []
scope "/v1" do
post "/customers", CustomerController, :create
post "/customers/individual", CustomerController, :create
post "/customers/corporate", CustomerController, :create_corporate
get "/customers", CustomerController, :index
get "/customers/:id", CustomerController, :show
end
end
if Mix.env() == :dev do
scope "/swaggerui" do
get "/", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi"
end
end
end

View File

@@ -1,37 +1,140 @@
defmodule CustomerServiceWeb.Schemas do
defmodule CustomerResponse do
defmodule CustomerServiceWeb.Schemas.Customer do
alias OpenApiSpex.Schema
defmodule PaginationMeta do
require OpenApiSpex
alias OpenApiSpex.Schema
OpenApiSpex.schema(%{
title: "Customer",
title: "PaginationMeta",
type: :object,
properties: %{
id: %Schema{type: :string, format: :uuid},
first_name: %Schema{type: :string},
last_name: %Schema{type: :string},
birth_date: %Schema{type: :string, format: :date},
gender: %Schema{type: :string},
email: %Schema{type: :string, format: :email},
phone: %Schema{type: :string}
total_count: %Schema{type: :integer},
total_pages: %Schema{type: :integer},
current_page: %Schema{type: :integer},
page_size: %Schema{type: :integer},
has_next: %Schema{type: :boolean},
has_prev: %Schema{type: :boolean}
}
})
end
defmodule CreateCustomerRequest do
defmodule IndividualCustomerData do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "IndividualCustomer",
type: :object,
properties: %{
id: %Schema{type: :string, format: :uuid},
customer_type: %Schema{type: :string, enum: ["individual"]},
first_name: %Schema{type: :string},
last_name: %Schema{type: :string},
email: %Schema{type: :string, format: :email},
phone: %Schema{type: :string},
birth_date: %Schema{type: :string, format: :date},
gender: %Schema{type: :string},
document_id: %Schema{type: :string}
}
})
end
defmodule CorporateCustomerData do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CorporateCustomer",
type: :object,
properties: %{
id: %Schema{type: :string, format: :uuid},
customer_type: %Schema{type: :string, enum: ["corporate"]},
legal_name: %Schema{type: :string},
commercial_name: %Schema{type: :string},
ruc: %Schema{type: :string},
legal_rep_name: %Schema{type: :string},
legal_rep_document_id: %Schema{type: :string},
email: %Schema{type: :string, format: :email},
phone: %Schema{type: :string},
address: %Schema{type: :string}
}
})
end
defmodule CustomerData do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "Customer",
oneOf: [IndividualCustomerData, CorporateCustomerData],
discriminator: %OpenApiSpex.Discriminator{
propertyName: "customer_type",
mapping: %{
"individual" => IndividualCustomerData,
"corporate" => CorporateCustomerData
}
}
})
end
defmodule CreateCustomer do
require OpenApiSpex
alias OpenApiSpex.Schema
OpenApiSpex.schema(%{
title: "CreateCustomer",
type: :object,
required: [:first_name, :last_name],
properties: %{
first_name: %Schema{type: :string},
last_name: %Schema{type: :string},
email: %Schema{type: :string, format: :email},
phone: %Schema{type: :string},
birth_date: %Schema{type: :string, format: :date},
gender: %Schema{type: :string},
document_id: %Schema{type: :string}
}
})
end
defmodule CreateCorporateCustomer do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CreateCorporateCustomer",
type: :object,
required: [:legal_name, :ruc],
properties: %{
legal_name: %Schema{type: :string},
commercial_name: %Schema{type: :string},
ruc: %Schema{type: :string},
legal_rep_name: %Schema{type: :string},
legal_rep_document_id: %Schema{type: :string},
email: %Schema{type: :string, format: :email},
phone: %Schema{type: :string}
phone: %Schema{type: :string},
address: %Schema{type: :string}
}
})
end
defmodule CustomerListResponse do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CustomerListResponse",
type: :object,
properties: %{
data: %Schema{type: :array, items: CustomerData},
meta: PaginationMeta
}
})
end
defmodule CustomerResponse do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CustomerResponse",
type: :object,
properties: %{
data: CustomerData
}
})
end