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,66 @@
name: Build and Publish
on:
push:
branches:
- main
env:
CHART_NAME: ${{ github.event.repository.name }}
IMAGE_NAME: ${{ github.event.repository.name }}
jobs:
build-release:
runs-on: nix
permissions:
id-token: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Docker Image via Nix Flake
run: |
nix build .#dockerImage --print-build-logs
docker load < result
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ github.server_url }}
username: ${{ secrets.CI_USER }}
password: ${{ secrets.CI_PASSWORD }}
- name: Tag and Push Docker Image
run: |
VERSION=${{ github.run_number }}
REGISTRY=${GITHUB_SERVER_URL#https://}
TARGET_IMAGE=$REGISTRY/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
SOURCE_IMAGE=$(docker load < result | awk '{print $3}')
docker tag $SOURCE_IMAGE $TARGET_IMAGE:$VERSION
docker tag $SOURCE_IMAGE $TARGET_IMAGE:latest
docker push $TARGET_IMAGE:$VERSION
docker push $TARGET_IMAGE:latest
- name: Setup Helm
uses: azure/setup-helm@v4
with:
version: v3.14.0
- name: Package Helm Chart
run: |
VERSION=${{ github.run_number }}
helm repo add bjw-s https://bjw-s-labs.github.io/helm-charts
helm dependency build ops/chart
helm package ops/chart --version $VERSION --app-version $VERSION
- name: Push Helm Chart to Gitea Registry
run: |
VERSION=${{ github.run_number }}
CHART_FILE=${{ env.CHART_NAME }}-${VERSION}.tgz
curl -f --user "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" \
-X POST \
--upload-file ./$CHART_FILE \
"${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm/api/charts"

View File

@@ -43,6 +43,8 @@ config :commanded,
config :commanded_ecto_projections,
repo: CustomerService.Repo
config :flop, repo: CustomerService.Repo
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

View File

@@ -1,17 +1,3 @@
import Config
# Force using SSL in production. This also sets the "strict-security-transport" header,
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
# Note `:force_ssl` is required to be set at compile-time.
config :customer_service, CustomerServiceWeb.Endpoint,
force_ssl: [rewrite_on: [:x_forwarded_proto]],
exclude: [
# paths: ["/health"],
hosts: ["localhost", "127.0.0.1"]
]
# Do not print debug messages in production
config :logger, level: :info
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.
config :logger, level: :debug

View File

@@ -1,27 +1,29 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
logger_level =
case System.get_env("LOG_LEVEL", "info") do
"debug" -> :debug
"info" -> :info
"warn" -> :warning
"error" -> :error
val when val in ["warning", "error"] -> :error
_ -> :info
end
config :logger, level: logger_level
config :logger, :console, format: {Logger.Formatter, :format}
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/customer_service start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :customer_service, CustomerServiceWeb.Endpoint, server: true
end
if cookie = System.get_env("RELEASE_COOKIE") do
config :elixir, :cookie, cookie
end
config :customer_service, CustomerServiceWeb.Endpoint,
http: [port: String.to_integer(System.get_env("PORT", "4000"))]
http: [port: String.to_integer(System.get_env("PORT", "8080"))]
if config_env() == :prod do
database_url =
@@ -34,18 +36,15 @@ if config_env() == :prod do
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :customer_service, CustomerService.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
# For machines with several cores, consider starting multiple pools of `pool_size`
# pool_count: 4,
pool_size: 1,
socket_options: maybe_ipv6
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
config :customer_service, CustomerService.EventStore,
serializer: Commanded.Serialization.JsonSerializer,
url: database_url,
pool_size: 1
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
@@ -58,45 +57,9 @@ if config_env() == :prod do
config :customer_service, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :customer_service, CustomerServiceWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
url: [host: host, port: 80, scheme: "http"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0}
],
secret_key_base: secret_key_base
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :customer_service, CustomerServiceWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :customer_service, CustomerServiceWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
end

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

View File

@@ -53,7 +53,8 @@ defmodule CustomerService.MixProject do
{:eventstore, "~> 1.4"},
{:open_api_spex, "~> 3.20"},
{:commanded_eventstore_adapter, "~> 1.4"},
{:cors_plug, "~> 3.0"}
{:cors_plug, "~> 3.0"},
{:flop, "~> 0.26"}
]
end

View File

@@ -11,11 +11,13 @@
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
"eventstore": {:hex, :eventstore, "1.4.8", "26778c991cfb078f3906a4267060efc7bb5e5943f69ddb8ae6fb60f07042a66e", [:mix], [{:fsm, "~> 0.3", [hex: :fsm, repo: "hexpm", optional: false]}, {:gen_stage, "~> 1.2", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "30c914602fdea8db5992a90ecb1f84068531e764cf0c066be71ff0eec4e3bcb9"},
"flop": {:hex, :flop, "0.26.3", "9bc700b34f96a57e56aaa89b850926356311372556eacd5a1abe0fdd0ea40bf2", [:mix], [{:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}], "hexpm", "cd77588229778ac55560c90dfbe15ab6486773f067d6e52db9fa703b8c9a9d2d"},
"fsm": {:hex, :fsm, "0.3.1", "087aa9b02779a84320dc7a2d8464452b5308e29877921b2bde81cdba32a12390", [:mix], [], "hexpm", "fbf0d53f89e9082b326b0b5828b94b4c549ff9d1452bbfd00b4d1ac082208e96"},
"gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"open_api_spex": {:hex, :open_api_spex, "3.22.2", "0b3c4f572ee69cb6c936abf426b9d84d8eebd34960871fd77aead746f0d69cb0", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "0a4fc08472d75e9cfe96e0748c6b1565b3b4398f97bf43fcce41b41b6fd3fb33"},
"phoenix": {:hex, :phoenix, "1.8.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},

6
ops/chart/Chart.lock Normal file
View File

@@ -0,0 +1,6 @@
dependencies:
- name: common
repository: https://bjw-s-labs.github.io/helm-charts/
version: 4.6.2
digest: sha256:35e8f4e5d15d878c246a04eb51de580291f31203fa10e9e4d2318f16026b2061
generated: "2026-04-15T15:40:50.605667603-05:00"

14
ops/chart/Chart.yaml Normal file
View File

@@ -0,0 +1,14 @@
apiVersion: v2
name: customer-service
description: Customer service for insurance quotes and policies
type: application
version: 0.1.0
appVersion: "1.0.0"
keywords:
- elixir
- commanded
- cqrs
dependencies:
- name: common
version: "4.6.2"
repository: https://bjw-s-labs.github.io/helm-charts/

View File

@@ -0,0 +1,4 @@
{{/*
Render all resources provided by the common library
*/}}
{{- include "bjw-s.common.loader.all" . -}}

118
ops/chart/values.yaml Normal file
View File

@@ -0,0 +1,118 @@
controllers:
main:
enabled: true
type: deployment
replicas: 1
containers:
main:
image:
repository: gitea.corredorconect.com/software-engineering/customer-service
tag: '{{ $.Chart.AppVersion }}'
env:
LOG_LEVEL: info
MIX_ENV: prod
PORT: "8080"
PHX_HOST: "0.0.0.0"
PHX_SERVER: "true"
DATABASE_URL:
valueFrom:
secretKeyRef:
name: customer-service-cluster-pg-app
key: uri
SECRET_KEY_BASE:
valueFrom:
secretKeyRef:
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-secrets'
key: secretKeyBase
RELEASE_COOKIE:
valueFrom:
secretKeyRef:
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-secrets'
key: cookie
probes:
liveness:
enabled: true
custom: true
spec:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readiness:
enabled: true
custom: true
spec:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
service:
main:
controller: main
type: ClusterIP
ports:
http:
port: 8080
protocol: HTTP
rawResources:
password-generator:
enabled: true
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
suffix: password-generator
spec:
spec:
length: 32
noUpper: false
allowRepeat: true
secretKeys:
- cookie
- secretKeyBase
external-secret:
enabled: true
apiVersion: external-secrets.io/v1
kind: ExternalSecret
suffix: secrets
spec:
spec:
refreshInterval: 0s
secretStoreRef:
name: cluster-secrets-store
kind: ClusterSecretStore
target:
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-secrets'
creationPolicy: Owner
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-password-generator'
cluster:
enabled: true
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
suffix: pg
spec:
spec:
description: "PostgreSQL cluster for customer-service"
instances: 1
bootstrap:
initdb:
database: customer_service
owner: customer_service
storage:
size: 5Gi

View File

@@ -10,7 +10,14 @@ defmodule CustomerService.Repo.Migrations.AddCustomerTable do
add :gender, :string
add :email, :string
add :phone, :string
add :document_id, :string
add :customer_type, :string, null: false, default: "individual"
add :legal_name, :string
add :commercial_name, :string
add :ruc, :string
add :legal_rep_name, :string
add :legal_rep_document_id, :string
add :address, :string
timestamps()
end

38
rel/vm.args.eex Normal file
View File

@@ -0,0 +1,38 @@
## --- memory optimisation (embedded/low-RAM targets) ---
## disable carrier utilization limit
+MBacul 0
+MHacul 0
## smaller carrier sizes
+MBsmbcs 64
+MBlmbcs 128
+MHsmbcs 64
+MHlmbcs 128
## smaller main carrier
+MMscs 20
## --- scheduler tuning ---
+S 1:1
+SDcpu 1:1
+SDio 1
## --- resource limits ---
+t 100000
+P 50000
+Q 8192
## --- general ---
+c false
+sbwt none
+sbwtdcpu none
+sbwtdio none
+swt very_low
+swtdcpu very_low
+swtdio very_low
+secio false
+K true

View File

@@ -2,7 +2,9 @@ defmodule CustomerServiceWeb.ErrorJSONTest do
use CustomerServiceWeb.ConnCase, async: true
test "renders 404" do
assert CustomerServiceWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
assert CustomerServiceWeb.ErrorJSON.render("404.json", %{}) == %{
errors: %{detail: "Not Found"}
}
end
test "renders 500" do