From b1b1c21e4ea6f3d50d5985d610727b53daf354c4 Mon Sep 17 00:00:00 2001 From: HaimKortovich Date: Wed, 15 Apr 2026 16:20:22 -0500 Subject: [PATCH] init commit --- .gitea/workflows/build-and-publish.yaml | 66 ++++++ config/config.exs | 2 + config/prod.exs | 16 +- config/runtime.exs | 87 ++----- .../aggregates/corporate_customer.ex | 51 ++++ lib/customer_service/aggregates/customer.ex | 14 +- lib/customer_service/commanded_app.ex | 5 + lib/customer_service/commands.ex | 18 +- lib/customer_service/customer/filters.ex | 19 ++ lib/customer_service/customer/queries.ex | 15 ++ lib/customer_service/events.ex | 19 +- lib/customer_service/projections/customer.ex | 64 +++++- lib/customer_service/projectors/customer.ex | 39 +++- .../controllers/customer.ex | 217 +++++++++++++----- .../controllers/error_json.ex | 18 +- .../controllers/health_controller.ex | 15 ++ lib/customer_service_web/endpoint.ex | 1 + lib/customer_service_web/router.ex | 21 +- lib/customer_service_web/schemas/customer.ex | 131 +++++++++-- mix.exs | 3 +- mix.lock | 2 + ops/chart/Chart.lock | 6 + ops/chart/Chart.yaml | 14 ++ ops/chart/templates/common.tpl | 4 + ops/chart/values.yaml | 118 ++++++++++ .../20260224165602_add_customer_table.exs | 9 +- rel/vm.args.eex | 38 +++ .../controllers/error_json_test.exs | 4 +- 28 files changed, 822 insertions(+), 194 deletions(-) create mode 100644 .gitea/workflows/build-and-publish.yaml create mode 100644 lib/customer_service/aggregates/corporate_customer.ex create mode 100644 lib/customer_service/customer/filters.ex create mode 100644 lib/customer_service/customer/queries.ex create mode 100644 lib/customer_service_web/controllers/health_controller.ex create mode 100644 ops/chart/Chart.lock create mode 100644 ops/chart/Chart.yaml create mode 100644 ops/chart/templates/common.tpl create mode 100644 ops/chart/values.yaml create mode 100644 rel/vm.args.eex diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml new file mode 100644 index 0000000..7d4b2a9 --- /dev/null +++ b/.gitea/workflows/build-and-publish.yaml @@ -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" diff --git a/config/config.exs b/config/config.exs index 01811b2..d2f9703 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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" diff --git a/config/prod.exs b/config/prod.exs index ccf568a..a55f6ea 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -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 diff --git a/config/runtime.exs b/config/runtime.exs index 9a089ed..94a22e6 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 diff --git a/lib/customer_service/aggregates/corporate_customer.ex b/lib/customer_service/aggregates/corporate_customer.ex new file mode 100644 index 0000000..21dd567 --- /dev/null +++ b/lib/customer_service/aggregates/corporate_customer.ex @@ -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 diff --git a/lib/customer_service/aggregates/customer.ex b/lib/customer_service/aggregates/customer.ex index 8b3ff4f..82704a9 100644 --- a/lib/customer_service/aggregates/customer.ex +++ b/lib/customer_service/aggregates/customer.ex @@ -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 diff --git a/lib/customer_service/commanded_app.ex b/lib/customer_service/commanded_app.ex index 37d3510..52428fb 100644 --- a/lib/customer_service/commanded_app.ex +++ b/lib/customer_service/commanded_app.ex @@ -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 diff --git a/lib/customer_service/commands.ex b/lib/customer_service/commands.ex index 93c8a04..2de17a1 100644 --- a/lib/customer_service/commands.ex +++ b/lib/customer_service/commands.ex @@ -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 diff --git a/lib/customer_service/customer/filters.ex b/lib/customer_service/customer/filters.ex new file mode 100644 index 0000000..41829ce --- /dev/null +++ b/lib/customer_service/customer/filters.ex @@ -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 diff --git a/lib/customer_service/customer/queries.ex b/lib/customer_service/customer/queries.ex new file mode 100644 index 0000000..cb00d37 --- /dev/null +++ b/lib/customer_service/customer/queries.ex @@ -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 diff --git a/lib/customer_service/events.ex b/lib/customer_service/events.ex index 69ddc59..fa8f8f2 100644 --- a/lib/customer_service/events.ex +++ b/lib/customer_service/events.ex @@ -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 diff --git a/lib/customer_service/projections/customer.ex b/lib/customer_service/projections/customer.ex index d81e075..e3c7847 100644 --- a/lib/customer_service/projections/customer.ex +++ b/lib/customer_service/projections/customer.ex @@ -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 diff --git a/lib/customer_service/projectors/customer.ex b/lib/customer_service/projectors/customer.ex index bf2d67e..39ad102 100644 --- a/lib/customer_service/projectors/customer.ex +++ b/lib/customer_service/projectors/customer.ex @@ -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 diff --git a/lib/customer_service_web/controllers/customer.ex b/lib/customer_service_web/controllers/customer.ex index ed31589..c0d7574 100644 --- a/lib/customer_service_web/controllers/customer.ex +++ b/lib/customer_service_web/controllers/customer.ex @@ -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 diff --git a/lib/customer_service_web/controllers/error_json.ex b/lib/customer_service_web/controllers/error_json.ex index c3b1d42..7bf3ada 100644 --- a/lib/customer_service_web/controllers/error_json.ex +++ b/lib/customer_service_web/controllers/error_json.ex @@ -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 diff --git a/lib/customer_service_web/controllers/health_controller.ex b/lib/customer_service_web/controllers/health_controller.ex new file mode 100644 index 0000000..2fb2611 --- /dev/null +++ b/lib/customer_service_web/controllers/health_controller.ex @@ -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 diff --git a/lib/customer_service_web/endpoint.ex b/lib/customer_service_web/endpoint.ex index fbecc79..d608f90 100644 --- a/lib/customer_service_web/endpoint.ex +++ b/lib/customer_service_web/endpoint.ex @@ -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 diff --git a/lib/customer_service_web/router.ex b/lib/customer_service_web/router.ex index 0d7ff27..f15edef 100644 --- a/lib/customer_service_web/router.ex +++ b/lib/customer_service_web/router.ex @@ -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 diff --git a/lib/customer_service_web/schemas/customer.ex b/lib/customer_service_web/schemas/customer.ex index 4f6c8f5..f8963a5 100644 --- a/lib/customer_service_web/schemas/customer.ex +++ b/lib/customer_service_web/schemas/customer.ex @@ -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 diff --git a/mix.exs b/mix.exs index 3cf44e6..ccbe257 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index 44c3729..596b64d 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/ops/chart/Chart.lock b/ops/chart/Chart.lock new file mode 100644 index 0000000..7867e89 --- /dev/null +++ b/ops/chart/Chart.lock @@ -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" diff --git a/ops/chart/Chart.yaml b/ops/chart/Chart.yaml new file mode 100644 index 0000000..41c13bb --- /dev/null +++ b/ops/chart/Chart.yaml @@ -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/ diff --git a/ops/chart/templates/common.tpl b/ops/chart/templates/common.tpl new file mode 100644 index 0000000..b70187e --- /dev/null +++ b/ops/chart/templates/common.tpl @@ -0,0 +1,4 @@ +{{/* +Render all resources provided by the common library +*/}} +{{- include "bjw-s.common.loader.all" . -}} diff --git a/ops/chart/values.yaml b/ops/chart/values.yaml new file mode 100644 index 0000000..18d8f52 --- /dev/null +++ b/ops/chart/values.yaml @@ -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 diff --git a/priv/repo/migrations/20260224165602_add_customer_table.exs b/priv/repo/migrations/20260224165602_add_customer_table.exs index 26d8178..f33396e 100644 --- a/priv/repo/migrations/20260224165602_add_customer_table.exs +++ b/priv/repo/migrations/20260224165602_add_customer_table.exs @@ -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 diff --git a/rel/vm.args.eex b/rel/vm.args.eex new file mode 100644 index 0000000..06671bb --- /dev/null +++ b/rel/vm.args.eex @@ -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 diff --git a/test/customer_service_web/controllers/error_json_test.exs b/test/customer_service_web/controllers/error_json_test.exs index c12cd4d..eb7edb2 100644 --- a/test/customer_service_web/controllers/error_json_test.exs +++ b/test/customer_service_web/controllers/error_json_test.exs @@ -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