commit f566d04a0462f6375fe0626949d0075e254cfc53 Author: HaimKortovich Date: Wed Apr 15 15:31:56 2026 -0500 init commit diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml new file mode 100644 index 0000000..dc1cb1f --- /dev/null +++ b/.gitea/workflows/build-and-publish.yaml @@ -0,0 +1,68 @@ +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 }} + + # Strip https from server URL + REGISTRY=${GITHUB_SERVER_URL#https://} + + TARGET_IMAGE=$REGISTRY/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + + # Auto-detect the built image name (better version) + 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" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cebfbd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +provider_service-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b031fe9 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# ProviderService + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `provider_service` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:provider_service, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..70f0b9c --- /dev/null +++ b/config/config.exs @@ -0,0 +1,50 @@ +import Config + +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +config :provider_service, + ecto_repos: [ProviderService.Repo], + event_stores: [ProviderService.EventStore] + +config :provider_service, ProviderServiceWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [json: ProviderServiceWeb.ErrorJSON], + layout: false + ], + pubsub_server: ProviderService.PubSub, + live_view: [signing_salt: "zPStCmh9"] + +# Configure Elixir's Logger +config :logger, :default_formatter, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +config :provider_service, ProviderService.CommandedApp, + event_store: [ + adapter: Commanded.EventStore.Adapters.EventStore, + event_store: ProviderService.EventStore + ], + pub_sub: :local, + registry: :local + +config :commanded, + event_store_adapter: Commanded.EventStore.Adapters.EventStore + +config :commanded_ecto_projections, + repo: ProviderService.Repo + +config :flop, repo: ProviderService.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/dev.exs b/config/dev.exs new file mode 100644 index 0000000..4ba6cf2 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,41 @@ +import Config + +config :provider_service, ProviderService.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "provider_service_dev", + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: 10 + +config :provider_service, ProviderServiceWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + adapter: Bandit.PhoenixAdapter, + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "localdevsecretkeybase1234567890localdevsecretkeybase1234567890xx" + +config :ex_aws, + access_key_id: "minioadmin", + secret_access_key: "minioadmin", + region: "us-east-1" + +config :ex_aws, :s3, + scheme: "http://", + host: "localhost", + port: 9000 + +config :provider_service, ProviderService.EventStore, + serializer: Commanded.Serialization.JsonSerializer, + username: "postgres", + password: "postgres", + database: "provider_service_eventstore_dev", + hostname: "localhost", + pool_size: 10 + +config :provider_service, :s3_bucket, "policy-bucket" + +config :provider_service, + solicitation_service_url: "http://localhost:8081" diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..a55f6ea --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,3 @@ +import Config + +config :logger, level: :debug diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..5305271 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,80 @@ +import Config + +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} + +s3_host = System.get_env("S3_HOST", "dev.s3.corredorconect.com") +s3_port = System.get_env("S3_PORT", "443") + +config :ex_aws, + access_key_id: System.get_env("AWS_ACCESS_KEY_ID"), + secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY"), + region: System.get_env("AWS_REGION", "us-east-1") + +config :ex_aws, :s3, + scheme: "https://", + host: s3_host, + port: s3_port + +config :provider_service, :s3_bucket, System.get_env("S3_BUCKET") + +if System.get_env("PHX_SERVER") do + config :provider_service, ProviderServiceWeb.Endpoint, server: true +end + +if cookie = System.get_env("RELEASE_COOKIE") do + config :elixir, :cookie, cookie +end + +config :provider_service, ProviderServiceWeb.Endpoint, + http: [port: String.to_integer(System.get_env("PORT", "8080"))] + +if config_env() == :prod do + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + + maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] + + config :provider_service, ProviderService.Repo, + url: database_url, + pool_size: 1, + socket_options: maybe_ipv6 + + config :provider_service, ProviderService.EventStore, + serializer: Commanded.Serialization.JsonSerializer, + url: database_url, + pool_size: 1 + + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + + config :provider_service, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + + config :provider_service, ProviderServiceWeb.Endpoint, + url: [host: host, port: 80, scheme: "http"], + http: [ + ip: {0, 0, 0, 0, 0, 0, 0, 0} + ], + secret_key_base: secret_key_base +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..2dcb62f --- /dev/null +++ b/config/test.exs @@ -0,0 +1,27 @@ +import Config + +config :provider_service, ProviderService.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "provider_service_test#{System.get_env("MIX_TEST_PARTITION")}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2 + +config :provider_service, ProviderServiceWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "mf3rysPsftCpSAdQfBIFEKFpjA1e9tGi9+jbxhNTs5qC3pC9LSn6P/kWlZatl9a0", + server: false + +config :logger, level: :warning + +config :phoenix, :plug_init_mode, :runtime + +config :phoenix, + sort_verified_routes_query_params: true + +config :provider_service, ProviderService.Application, + event_store: [ + adapter: Commanded.EventStore.Adapters.InMemory, + serializer: Commanded.Serialization.JsonSerializer + ] diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..4cee253 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776169885, + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a7300ee --- /dev/null +++ b/flake.nix @@ -0,0 +1,55 @@ +{ + description = "Provider Service - Phoenix/Commanded CQRS/ES service"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + beamPackages = pkgs.beamPackages; + pname = "provider_service"; + version = "1.0.0"; + mixFodDeps = beamPackages.fetchMixDeps { + inherit pname version; + src = pkgs.lib.cleanSource ./.; + sha256 = "sha256-t6D0qjPNnAsYtHbwOCbuNBUwcrkvmmGf4/LeOIWgjyw="; + }; + package = beamPackages.mixRelease { + inherit pname version mixFodDeps; + src = pkgs.lib.cleanSource ./.; + meta = { + mainProgram = "provider_service"; + }; + removeCookie = false; + }; + dockerImage = pkgs.dockerTools.buildLayeredImage { + name = "provider_service"; + contents = [ package pkgs.bashInteractive pkgs.busybox pkgs.shadow ]; + config = { + Cmd = [ "${package}/bin/provider_service" "start" ]; + }; + }; + in + { + packages.default = package; + packages.dockerImage = dockerImage; + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + elixir + elixir-ls + kubernetes-helm + git + ]; + }; + } + ); +} diff --git a/lib/provider_service/aggregates/provider.ex b/lib/provider_service/aggregates/provider.ex new file mode 100644 index 0000000..a2a9ed0 --- /dev/null +++ b/lib/provider_service/aggregates/provider.ex @@ -0,0 +1,341 @@ +defmodule ProviderService.Aggregates.Provider do + defstruct [ + :provider_id, + :name, + :email, + :phone, + :contact_name, + :ruc, + :address, + :active, + templates: %{}, + default_templates: %{} + ] + + alias ProviderService.Commands.{ + RegisterProvider, + UpdateProvider, + DeactivateProvider, + ReactivateProvider, + AddProviderTemplate, + ActivateProviderTemplate, + DeactivateProviderTemplate, + SetDefaultProviderTemplate, + RemoveProviderTemplate + } + + alias ProviderService.Events.{ + ProviderRegistered, + ProviderUpdated, + ProviderDeactivated, + ProviderReactivated, + ProviderTemplateAdded, + ProviderTemplateActivated, + ProviderTemplateDeactivated, + ProviderTemplateDefaultSet, + ProviderTemplateRemoved + } + + # --------------------------------------------------------------------------- + # Execute — Provider + # --------------------------------------------------------------------------- + + def execute(%__MODULE__{provider_id: nil}, %RegisterProvider{} = cmd) do + %ProviderRegistered{ + provider_id: cmd.provider_id, + name: cmd.name, + email: cmd.email, + phone: cmd.phone, + contact_name: cmd.contact_name, + ruc: cmd.ruc, + address: cmd.address, + registered_at: DateTime.utc_now() + } + end + + def execute(%__MODULE__{active: false}, %UpdateProvider{}), + do: {:error, :provider_inactive} + + def execute(%__MODULE__{} = agg, %UpdateProvider{} = cmd) do + %ProviderUpdated{ + provider_id: agg.provider_id, + name: cmd.name, + email: cmd.email, + phone: cmd.phone, + contact_name: cmd.contact_name, + ruc: cmd.ruc, + address: cmd.address, + updated_at: DateTime.utc_now() + } + end + + def execute(%__MODULE__{active: false}, %DeactivateProvider{}), + do: {:error, :already_inactive} + + def execute(%__MODULE__{} = agg, %DeactivateProvider{} = cmd) do + %ProviderDeactivated{ + provider_id: agg.provider_id, + deactivated_by: cmd.deactivated_by, + deactivated_at: DateTime.utc_now() + } + end + + def execute(%__MODULE__{active: true}, %ReactivateProvider{}), + do: {:error, :already_active} + + def execute(%__MODULE__{} = agg, %ReactivateProvider{} = cmd) do + %ProviderReactivated{ + provider_id: agg.provider_id, + reactivated_by: cmd.reactivated_by, + reactivated_at: DateTime.utc_now() + } + end + + # --------------------------------------------------------------------------- + # Execute — Templates + # --------------------------------------------------------------------------- + + def execute(%__MODULE__{active: false}, %AddProviderTemplate{}), + do: {:error, :provider_inactive} + + def execute(%__MODULE__{} = agg, %AddProviderTemplate{} = cmd) do + existing = get_in(agg.templates, [cmd.policy_type, cmd.client_type]) || [] + version = length(existing) + 1 + + %ProviderTemplateAdded{ + provider_id: agg.provider_id, + template_id: cmd.template_id, + policy_type: cmd.policy_type, + client_type: cmd.client_type, + s3_key: cmd.s3_key, + fields: cmd.fields, + version: version, + added_at: DateTime.utc_now() + } + end + + def execute(%__MODULE__{} = agg, %ActivateProviderTemplate{} = cmd) do + case find_template(agg, cmd.policy_type, cmd.client_type, cmd.template_id) do + nil -> + {:error, :template_not_found} + + _ -> + %ProviderTemplateActivated{ + provider_id: agg.provider_id, + template_id: cmd.template_id, + policy_type: cmd.policy_type, + client_type: cmd.client_type, + activated_at: DateTime.utc_now() + } + end + end + + def execute(%__MODULE__{} = agg, %DeactivateProviderTemplate{} = cmd) do + case find_template(agg, cmd.policy_type, cmd.client_type, cmd.template_id) do + nil -> + {:error, :template_not_found} + + _ -> + %ProviderTemplateDeactivated{ + provider_id: agg.provider_id, + template_id: cmd.template_id, + policy_type: cmd.policy_type, + client_type: cmd.client_type, + deactivated_at: DateTime.utc_now() + } + end + end + + def execute(%__MODULE__{} = agg, %SetDefaultProviderTemplate{} = cmd) do + case find_template(agg, cmd.policy_type, cmd.client_type, cmd.template_id) do + nil -> + {:error, :template_not_found} + + %{active: false} -> + {:error, :template_not_active} + + _ -> + %ProviderTemplateDefaultSet{ + provider_id: agg.provider_id, + template_id: cmd.template_id, + policy_type: cmd.policy_type, + client_type: cmd.client_type, + set_at: DateTime.utc_now() + } + end + end + + def execute(%__MODULE__{} = agg, %RemoveProviderTemplate{} = cmd) do + case find_template(agg, cmd.policy_type, cmd.client_type, cmd.template_id) do + nil -> + {:error, :template_not_found} + + _ -> + %ProviderTemplateRemoved{ + provider_id: agg.provider_id, + template_id: cmd.template_id, + policy_type: cmd.policy_type, + client_type: cmd.client_type, + removed_at: DateTime.utc_now() + } + end + end + + # --------------------------------------------------------------------------- + # Apply — Provider + # --------------------------------------------------------------------------- + + def apply(%__MODULE__{} = agg, %ProviderRegistered{} = e) do + %__MODULE__{ + agg + | provider_id: e.provider_id, + name: e.name, + email: e.email, + phone: e.phone, + contact_name: e.contact_name, + ruc: e.ruc, + address: e.address, + active: true + } + end + + def apply(%__MODULE__{} = agg, %ProviderUpdated{} = e) do + %__MODULE__{ + agg + | name: e.name, + email: e.email, + phone: e.phone, + contact_name: e.contact_name, + ruc: e.ruc, + address: e.address + } + end + + def apply(%__MODULE__{} = agg, %ProviderDeactivated{}), + do: %__MODULE__{agg | active: false} + + def apply(%__MODULE__{} = agg, %ProviderReactivated{}), + do: %__MODULE__{agg | active: true} + + # --------------------------------------------------------------------------- + # Apply — Templates + # --------------------------------------------------------------------------- + + def apply(%__MODULE__{} = agg, %ProviderTemplateAdded{} = e) do + existing = get_in(agg.templates, [e.policy_type, e.client_type]) || [] + + templates = + agg.templates + |> Map.update(e.policy_type, %{e.client_type => []}, fn inner -> + Map.update(inner, e.client_type, [], fn list -> list end) + end) + |> put_in( + [e.policy_type, e.client_type], + existing ++ + [ + %{ + template_id: e.template_id, + s3_key: e.s3_key, + fields: e.fields, + version: e.version, + active: true + } + ] + ) + + %__MODULE__{agg | templates: templates} + end + + def apply(%__MODULE__{} = agg, %ProviderTemplateActivated{} = e) do + templates = + update_template( + agg.templates, + e.policy_type, + e.client_type, + e.template_id, + &Map.put(&1, :active, true) + ) + + %__MODULE__{agg | templates: templates} + end + + def apply(%__MODULE__{} = agg, %ProviderTemplateDeactivated{} = e) do + templates = + update_template( + agg.templates, + e.policy_type, + e.client_type, + e.template_id, + &Map.put(&1, :active, false) + ) + + template_id = e.template_id + # Clear default if the deactivated template was the default + default_templates = + case get_in(agg.default_templates, [e.policy_type, e.client_type]) do + ^template_id -> + update_in(agg.default_templates, [e.policy_type], &Map.delete(&1, e.client_type)) + + _ -> + agg.default_templates + end + + %__MODULE__{agg | templates: templates, default_templates: default_templates} + end + + def apply(%__MODULE__{} = agg, %ProviderTemplateDefaultSet{} = e) do + default_templates = + agg.default_templates + |> Map.update(e.policy_type, %{e.client_type => e.template_id}, fn inner -> + Map.put(inner, e.client_type, e.template_id) + end) + + %__MODULE__{agg | default_templates: default_templates} + end + + def apply(%__MODULE__{} = agg, %ProviderTemplateRemoved{} = e) do + templates = + agg.templates + |> update_in([e.policy_type, e.client_type], fn list -> + Enum.reject(list || [], &(&1.template_id == e.template_id)) + end) + + template_id = e.template_id + # Clear default if the removed template was the default + default_templates = + case get_in(agg.default_templates, [e.policy_type, e.client_type]) do + ^template_id -> + update_in(agg.default_templates, [e.policy_type], &Map.delete(&1, e.client_type)) + + _ -> + agg.default_templates + end + + %__MODULE__{agg | templates: templates, default_templates: default_templates} + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + # templates structure: %{ policy_type => %{ client_type => [%{template_id, ...}] } } + # default_templates: %{ policy_type => %{ client_type => template_id } } + + defp find_template(agg, policy_type, client_type, template_id) do + agg.templates + |> get_in([policy_type, client_type]) + |> List.wrap() + |> Enum.find(&(&1.template_id == template_id)) + end + + defp update_template(templates, policy_type, client_type, template_id, fun) do + templates + |> Map.update(policy_type, %{}, fn inner -> + Map.update(inner, client_type, [], fn list -> + Enum.map(list, fn t -> + if t.template_id == template_id, do: fun.(t), else: t + end) + end) + end) + end +end diff --git a/lib/provider_service/application.ex b/lib/provider_service/application.ex new file mode 100644 index 0000000..bbb425c --- /dev/null +++ b/lib/provider_service/application.ex @@ -0,0 +1,15 @@ +defmodule ProviderService.Application do + use Application + + def start(_type, _args) do + children = [ + ProviderService.Repo, + ProviderService.CommandedApp, + ProviderService.Projections.ProviderProjection, + ProviderServiceWeb.Endpoint + ] + + opts = [strategy: :one_for_one, name: ProviderService.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/provider_service/commanded_app.ex b/lib/provider_service/commanded_app.ex new file mode 100644 index 0000000..10c2c64 --- /dev/null +++ b/lib/provider_service/commanded_app.ex @@ -0,0 +1,40 @@ +defmodule ProviderService.Router do + use Commanded.Commands.Router + + alias ProviderService.Aggregates.Provider + + alias ProviderService.Commands.{ + RegisterProvider, + UpdateProvider, + DeactivateProvider, + ReactivateProvider, + AddProviderTemplate, + ActivateProviderTemplate, + DeactivateProviderTemplate, + SetDefaultProviderTemplate, + RemoveProviderTemplate + } + + identify(Provider, by: :provider_id) + + dispatch( + [ + RegisterProvider, + UpdateProvider, + DeactivateProvider, + ReactivateProvider, + AddProviderTemplate, + ActivateProviderTemplate, + DeactivateProviderTemplate, + SetDefaultProviderTemplate, + RemoveProviderTemplate + ], + to: Provider + ) +end + +defmodule ProviderService.CommandedApp do + use Commanded.Application, otp_app: :provider_service + + router(ProviderService.Router) +end diff --git a/lib/provider_service/commands/provider.ex b/lib/provider_service/commands/provider.ex new file mode 100644 index 0000000..4e87f85 --- /dev/null +++ b/lib/provider_service/commands/provider.ex @@ -0,0 +1,37 @@ +defmodule ProviderService.Commands do + defmodule RegisterProvider do + defstruct [:provider_id, :name, :email, :phone, :contact_name, :ruc, :address] + end + + defmodule UpdateProvider do + defstruct [:provider_id, :name, :email, :phone, :contact_name, :ruc, :address] + end + + defmodule DeactivateProvider do + defstruct [:provider_id, :deactivated_by] + end + + defmodule ReactivateProvider do + defstruct [:provider_id, :reactivated_by] + end + + defmodule AddProviderTemplate do + defstruct [:provider_id, :template_id, :policy_type, :s3_key, :fields, :client_type] + end + + defmodule ActivateProviderTemplate do + defstruct [:provider_id, :template_id, :policy_type, :client_type] + end + + defmodule DeactivateProviderTemplate do + defstruct [:provider_id, :template_id, :policy_type, :client_type] + end + + defmodule SetDefaultProviderTemplate do + defstruct [:provider_id, :template_id, :policy_type, :client_type] + end + + defmodule RemoveProviderTemplate do + defstruct [:provider_id, :template_id, :policy_type, :client_type] + end +end diff --git a/lib/provider_service/event_store.ex b/lib/provider_service/event_store.ex new file mode 100644 index 0000000..bfe0f7c --- /dev/null +++ b/lib/provider_service/event_store.ex @@ -0,0 +1,3 @@ +defmodule ProviderService.EventStore do + use EventStore, otp_app: :provider_service +end diff --git a/lib/provider_service/events/provider.ex b/lib/provider_service/events/provider.ex new file mode 100644 index 0000000..28dd88f --- /dev/null +++ b/lib/provider_service/events/provider.ex @@ -0,0 +1,55 @@ +defmodule ProviderService.Events do + defmodule ProviderRegistered do + @derive Jason.Encoder + defstruct [:provider_id, :name, :email, :phone, :contact_name, :ruc, :address, :registered_at] + end + + defmodule ProviderUpdated do + @derive Jason.Encoder + defstruct [:provider_id, :name, :email, :phone, :contact_name, :ruc, :address, :updated_at] + end + + defmodule ProviderDeactivated do + @derive Jason.Encoder + defstruct [:provider_id, :deactivated_by, :deactivated_at] + end + + defmodule ProviderReactivated do + @derive Jason.Encoder + defstruct [:provider_id, :reactivated_by, :reactivated_at] + end + + defmodule ProviderTemplateAdded do + @derive Jason.Encoder + defstruct [ + :provider_id, + :template_id, + :policy_type, + :s3_key, + :fields, + :version, + :added_at, + :client_type + ] + end + + defmodule ProviderTemplateActivated do + @derive Jason.Encoder + defstruct [:provider_id, :template_id, :policy_type, :activated_at, :client_type] + end + + defmodule ProviderTemplateDeactivated do + @derive Jason.Encoder + defstruct [:provider_id, :template_id, :policy_type, :deactivated_at, :client_type] + end + + defmodule ProviderTemplateDefaultSet do + @derive Jason.Encoder + defstruct [:provider_id, :template_id, :policy_type, :set_at, :client_type] + end + + defmodule ProviderTemplateRemoved do + @derive Jason.Encoder + defstruct [:provider_id, :template_id, :policy_type, :removed_at, :client_type] + end +end diff --git a/lib/provider_service/projections/provider.ex b/lib/provider_service/projections/provider.ex new file mode 100644 index 0000000..9ea09a3 --- /dev/null +++ b/lib/provider_service/projections/provider.ex @@ -0,0 +1,34 @@ +defmodule ProviderService.Projections.Provider do + use Ecto.Schema + + @derive { + Flop.Schema, + filterable: [:active, :search], + sortable: [:name, :inserted_at], + default_limit: 20, + max_limit: 100, + custom_fields: [ + search: [ + filter: {ProviderService.Projections.ProviderFilters, :search, []}, + ecto_type: :string, + operators: [:==] + ] + ] + } + + @primary_key {:provider_id, :string, autogenerate: false} + + schema "providers" do + field(:name, :string) + field(:email, :string) + field(:phone, :string) + field(:contact_name, :string) + field(:ruc, :string) + field(:address, :string) + field(:active, :boolean, default: true) + field(:templates, :map, default: %{}) + field(:default_templates, :map, default: %{}) + + timestamps(type: :utc_datetime_usec) + end +end diff --git a/lib/provider_service/projections/provider_filters.ex b/lib/provider_service/projections/provider_filters.ex new file mode 100644 index 0000000..0d03a8a --- /dev/null +++ b/lib/provider_service/projections/provider_filters.ex @@ -0,0 +1,16 @@ +defmodule ProviderService.Projections.ProviderFilters do + import Ecto.Query + + def search(query, %Flop.Filter{value: value}, _opts) do + term = "%#{value}%" + + where( + query, + [p], + ilike(p.name, ^term) or + ilike(p.email, ^term) or + ilike(p.contact_name, ^term) or + ilike(p.ruc, ^term) + ) + end +end diff --git a/lib/provider_service/projections/provider_projection.ex b/lib/provider_service/projections/provider_projection.ex new file mode 100644 index 0000000..5b6161d --- /dev/null +++ b/lib/provider_service/projections/provider_projection.ex @@ -0,0 +1,196 @@ +defmodule ProviderService.Projections.ProviderProjection do + use Commanded.Projections.Ecto, + application: ProviderService.CommandedApp, + repo: ProviderService.Repo, + name: "ProviderProjection", + consistency: :strong + + alias ProviderService.Events.{ + ProviderRegistered, + ProviderUpdated, + ProviderDeactivated, + ProviderReactivated, + ProviderTemplateAdded, + ProviderTemplateActivated, + ProviderTemplateDeactivated, + ProviderTemplateDefaultSet, + ProviderTemplateRemoved + } + + alias ProviderService.Projections.Provider + import Ecto.Query + + project(%ProviderRegistered{} = e, _meta, fn multi -> + Ecto.Multi.insert(multi, :provider, %Provider{ + provider_id: e.provider_id, + name: e.name, + email: e.email, + phone: e.phone, + contact_name: e.contact_name, + ruc: e.ruc, + address: e.address, + active: true, + templates: %{}, + default_templates: %{} + }) + end) + + project(%ProviderUpdated{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> {:ok, repo.get!(Provider, e.provider_id)} end) + |> Ecto.Multi.update(:provider, fn %{fetch: p} -> + Ecto.Changeset.change(p, + name: e.name, + email: e.email, + phone: e.phone, + contact_name: e.contact_name, + ruc: e.ruc, + address: e.address + ) + end) + end) + + project(%ProviderDeactivated{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> {:ok, repo.get!(Provider, e.provider_id)} end) + |> Ecto.Multi.update(:provider, fn %{fetch: p} -> + Ecto.Changeset.change(p, active: false) + end) + end) + + project(%ProviderReactivated{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> {:ok, repo.get!(Provider, e.provider_id)} end) + |> Ecto.Multi.update(:provider, fn %{fetch: p} -> + Ecto.Changeset.change(p, active: true) + end) + end) + + # templates: %{ policy_type => %{ client_type => [template] } } + # default_templates: %{ policy_type => %{ client_type => template_id } } + + project(%ProviderTemplateAdded{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> {:ok, repo.get!(Provider, e.provider_id)} end) + |> Ecto.Multi.update(:provider, fn %{fetch: p} -> + template = %{ + "template_id" => e.template_id, + "client_type" => e.client_type, + "s3_key" => e.s3_key, + "fields" => e.fields || [], + "version" => e.version, + "active" => false + } + + updated = + p.templates + |> Map.update(e.policy_type, %{e.client_type => [template]}, fn inner -> + Map.update(inner, e.client_type, [template], fn list -> list ++ [template] end) + end) + + Ecto.Changeset.change(p, templates: updated) + end) + end) + + project(%ProviderTemplateActivated{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> {:ok, repo.get!(Provider, e.provider_id)} end) + |> Ecto.Multi.update(:provider, fn %{fetch: p} -> + updated = + update_template_field( + p.templates, + e.policy_type, + e.client_type, + e.template_id, + "active", + true + ) + + Ecto.Changeset.change(p, templates: updated) + end) + end) + + project(%ProviderTemplateDeactivated{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> {:ok, repo.get!(Provider, e.provider_id)} end) + |> Ecto.Multi.update(:provider, fn %{fetch: p} -> + updated = + update_template_field( + p.templates, + e.policy_type, + e.client_type, + e.template_id, + "active", + false + ) + + template_id = e.template_id + + default_templates = + case get_in(p.default_templates, [e.policy_type, e.client_type]) do + ^template_id -> + Map.update(p.default_templates, e.policy_type, %{}, &Map.delete(&1, e.client_type)) + + _ -> + p.default_templates + end + + Ecto.Changeset.change(p, templates: updated, default_templates: default_templates) + end) + end) + + project(%ProviderTemplateDefaultSet{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> {:ok, repo.get!(Provider, e.provider_id)} end) + |> Ecto.Multi.update(:provider, fn %{fetch: p} -> + default_templates = + p.default_templates + |> Map.update(e.policy_type, %{e.client_type => e.template_id}, fn inner -> + Map.put(inner, e.client_type, e.template_id) + end) + + Ecto.Changeset.change(p, default_templates: default_templates) + end) + end) + + project(%ProviderTemplateRemoved{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> {:ok, repo.get!(Provider, e.provider_id)} end) + |> Ecto.Multi.update(:provider, fn %{fetch: p} -> + updated = + p.templates + |> Map.update(e.policy_type, %{}, fn inner -> + Map.update(inner, e.client_type, [], fn list -> + Enum.reject(list, &(&1["template_id"] == e.template_id)) + end) + end) + + template_id = e.template_id + + default_templates = + case get_in(p.default_templates, [e.policy_type, e.client_type]) do + ^template_id -> + Map.update(p.default_templates, e.policy_type, %{}, &Map.delete(&1, e.client_type)) + + _ -> + p.default_templates + end + + Ecto.Changeset.change(p, templates: updated, default_templates: default_templates) + end) + end) + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp update_template_field(templates, policy_type, client_type, template_id, field, value) do + Map.update(templates, policy_type, %{}, fn inner -> + Map.update(inner, client_type, [], fn list -> + Enum.map(list, fn t -> + if t["template_id"] == template_id, do: Map.put(t, field, value), else: t + end) + end) + end) + end +end diff --git a/lib/provider_service/provider_service.ex b/lib/provider_service/provider_service.ex new file mode 100644 index 0000000..a84a9de --- /dev/null +++ b/lib/provider_service/provider_service.ex @@ -0,0 +1,18 @@ +defmodule ProviderService do + @moduledoc """ + Documentation for `ProviderService`. + """ + + @doc """ + Hello world. + + ## Examples + + iex> ProviderService.hello() + :world + + """ + def hello do + :world + end +end diff --git a/lib/provider_service/queries/provider_queries.ex b/lib/provider_service/queries/provider_queries.ex new file mode 100644 index 0000000..12d9cc0 --- /dev/null +++ b/lib/provider_service/queries/provider_queries.ex @@ -0,0 +1,34 @@ +defmodule ProviderService.Queries.ProviderQueries do + alias ProviderService.Projections.Provider + alias ProviderService.Repo + + def list_providers(params \\ %{}) do + Flop.validate_and_run(Provider, params, for: Provider) + end + + def get_provider(provider_id) do + case Repo.get(Provider, provider_id) do + nil -> {:error, :not_found} + provider -> {:ok, provider} + end + end + + def get_active_template(provider_id, policy_type) do + with {:ok, provider} <- get_provider(provider_id) do + default_id = Map.get(provider.default_templates, policy_type) + templates = Map.get(provider.templates, policy_type, []) + + result = + if default_id do + Enum.find(templates, &(&1["template_id"] == default_id)) + else + Enum.find(templates, &(&1["active"] == true)) + end + + case result do + nil -> {:error, :no_active_template} + template -> {:ok, template} + end + end + end +end diff --git a/lib/provider_service/repo.ex b/lib/provider_service/repo.ex new file mode 100644 index 0000000..3ba1dd4 --- /dev/null +++ b/lib/provider_service/repo.ex @@ -0,0 +1,5 @@ +defmodule ProviderService.Repo do + use Ecto.Repo, + otp_app: :provider_service, + adapter: Ecto.Adapters.Postgres +end diff --git a/lib/provider_service/s3.ex b/lib/provider_service/s3.ex new file mode 100644 index 0000000..d594d76 --- /dev/null +++ b/lib/provider_service/s3.ex @@ -0,0 +1,38 @@ +defmodule ProviderService.S3 do + @bucket Application.compile_env(:provider_service, :s3_bucket, "policy-bucket") + + def presigned_upload_url(s3_key) do + {:ok, url} = + ExAws.Config.new(:s3) + |> ExAws.S3.presigned_url(:put, @bucket, s3_key, + expires_in: 900, + query_params: [{"Content-Type", "application/pdf"}] + ) + + url + end + + def presigned_download_url(s3_key) do + {:ok, url} = + ExAws.Config.new(:s3) + |> ExAws.S3.presigned_url(:get, @bucket, s3_key, expires_in: 3600) + + url + end + + def delete(s3_key) do + ExAws.S3.delete_object(@bucket, s3_key) + |> ExAws.request() + end + + def upload(local_path, s3_key) do + local_path + |> File.read!() + |> then(&ExAws.S3.put_object(@bucket, s3_key, &1, content_type: "application/pdf")) + |> ExAws.request() + |> case do + {:ok, _} -> :ok + {:error, e} -> {:error, inspect(e)} + end + end +end diff --git a/lib/provider_service_web/api_spec.ex b/lib/provider_service_web/api_spec.ex new file mode 100644 index 0000000..21aae95 --- /dev/null +++ b/lib/provider_service_web/api_spec.ex @@ -0,0 +1,25 @@ +defmodule ProviderServiceWeb.ApiSpec do + alias OpenApiSpex.{OpenApi, Info, Server} + alias OpenApiSpex.{Info, OpenApi, Paths, Server} + alias ProviderServiceWeb.{Endpoint, Router} + + @behaviour OpenApiSpex.OpenApi + + @impl OpenApi + def spec do + %OpenApi{ + servers: [ + # Populate the Server info from a phoenix endpoint + Server.from_endpoint(Endpoint) + ], + info: %Info{ + title: "Provider Service", + version: "1.0" + }, + # Populate the paths from a phoenix router + paths: Paths.from_router(Router) + } + # Discover request/response schemas from path specs + |> OpenApiSpex.resolve_schema_modules() + end +end diff --git a/lib/provider_service_web/controllers/provider_controller.ex b/lib/provider_service_web/controllers/provider_controller.ex new file mode 100644 index 0000000..8381d5b --- /dev/null +++ b/lib/provider_service_web/controllers/provider_controller.ex @@ -0,0 +1,207 @@ +defmodule ProviderServiceWeb.ProviderController do + use ProviderServiceWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias ProviderService.CommandedApp + alias ProviderService.Queries.ProviderQueries + + alias ProviderService.Commands.{ + RegisterProvider, + UpdateProvider, + DeactivateProvider, + ReactivateProvider + } + + alias ProviderServiceWeb.Schemas.Provider, as: PS + + operation(:index, + summary: "List providers", + 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: {"Provider list", "application/json", PS.ProviderListResponse} + ] + ) + + def index(conn, params) do + case ProviderQueries.list_providers(params) do + {:ok, {providers, meta}} -> + conn + |> put_status(:ok) + |> json(%{ + data: Enum.map(providers, &provider_json/1), + meta: meta_json(meta) + }) + + {:error, _} -> + conn |> put_status(:bad_request) |> json(%{error: "invalid parameters"}) + end + end + + operation(:show, + summary: "Get provider", + parameters: [ + provider_id: [in: :path, type: :string, required: true] + ], + responses: [ + ok: {"Provider", "application/json", PS.ProviderResponse}, + not_found: {"Not found", "application/json", %OpenApiSpex.Schema{type: :object}} + ] + ) + + def show(conn, %{"provider_id" => provider_id}) do + case ProviderQueries.get_provider(provider_id) do + {:ok, provider} -> + conn |> put_status(:ok) |> json(%{data: provider_json(provider)}) + + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "not found"}) + end + end + + operation(:create, + summary: "Register provider", + request_body: {"Provider data", "application/json", PS.RegisterProvider, required: true}, + responses: [ + created: {"Provider registered", "application/json", PS.ProviderResponse} + ] + ) + + def create(conn, params) do + provider_id = params["provider_id"] + + command = %RegisterProvider{ + provider_id: provider_id, + name: params["name"], + email: params["email"], + phone: params["phone"], + contact_name: params["contact_name"], + ruc: params["ruc"], + address: params["address"] + } + + case CommandedApp.dispatch(command, consistency: :strong) do + :ok -> + {:ok, provider} = ProviderQueries.get_provider(provider_id) + conn |> put_status(:created) |> json(%{data: provider_json(provider)}) + + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)}) + end + end + + operation(:update, + summary: "Update provider", + parameters: [ + provider_id: [in: :path, type: :string, required: true] + ], + request_body: {"Provider data", "application/json", PS.UpdateProvider, required: true}, + responses: [ + ok: {"Provider updated", "application/json", PS.ProviderResponse} + ] + ) + + def update(conn, %{"provider_id" => provider_id} = params) do + command = %UpdateProvider{ + provider_id: provider_id, + name: params["name"], + email: params["email"], + phone: params["phone"], + contact_name: params["contact_name"], + ruc: params["ruc"], + address: params["address"] + } + + case CommandedApp.dispatch(command, consistency: :strong) do + :ok -> + {:ok, provider} = ProviderQueries.get_provider(provider_id) + conn |> put_status(:ok) |> json(%{data: provider_json(provider)}) + + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)}) + end + end + + operation(:deactivate, + summary: "Deactivate provider", + parameters: [ + provider_id: [in: :path, type: :string, required: true] + ], + responses: [ + ok: {"Provider deactivated", "application/json", PS.ProviderResponse} + ] + ) + + def deactivate(conn, %{"provider_id" => provider_id}) do + command = %DeactivateProvider{ + provider_id: provider_id, + deactivated_by: "system" + } + + case CommandedApp.dispatch(command, consistency: :strong) do + :ok -> + {:ok, provider} = ProviderQueries.get_provider(provider_id) + conn |> put_status(:ok) |> json(%{data: provider_json(provider)}) + + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)}) + end + end + + operation(:reactivate, + summary: "Reactivate provider", + parameters: [ + provider_id: [in: :path, type: :string, required: true] + ], + responses: [ + ok: {"Provider reactivated", "application/json", PS.ProviderResponse} + ] + ) + + def reactivate(conn, %{"provider_id" => provider_id}) do + command = %ReactivateProvider{ + provider_id: provider_id, + reactivated_by: "system" + } + + case CommandedApp.dispatch(command, consistency: :strong) do + :ok -> + {:ok, provider} = ProviderQueries.get_provider(provider_id) + conn |> put_status(:ok) |> json(%{data: provider_json(provider)}) + + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)}) + end + end + + defp provider_json(p) do + %{ + provider_id: p.provider_id, + name: p.name, + email: p.email, + phone: p.phone, + contact_name: p.contact_name, + ruc: p.ruc, + address: p.address, + active: p.active, + templates: p.templates, + default_templates: p.default_templates + } + end + + defp meta_json(meta) do + %{ + 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? + } + end +end diff --git a/lib/provider_service_web/controllers/template_controller.ex b/lib/provider_service_web/controllers/template_controller.ex new file mode 100644 index 0000000..74a1dba --- /dev/null +++ b/lib/provider_service_web/controllers/template_controller.ex @@ -0,0 +1,215 @@ +defmodule ProviderServiceWeb.TemplateController do + use ProviderServiceWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias ProviderService.CommandedApp + alias ProviderService.Queries.ProviderQueries + + alias ProviderService.Commands.{ + AddProviderTemplate, + ActivateProviderTemplate, + DeactivateProviderTemplate, + SetDefaultProviderTemplate, + RemoveProviderTemplate + } + + alias ProviderService.S3 + alias ProviderServiceWeb.Schemas.Provider, as: PS + + operation(:index, + summary: "List templates for provider", + parameters: [ + provider_id: [in: :path, type: :string, required: true] + ], + responses: [ + ok: {"Templates", "application/json", %OpenApiSpex.Schema{type: :object}} + ] + ) + + def index(conn, %{"provider_id" => provider_id}) do + case ProviderQueries.get_provider(provider_id) do + {:ok, provider} -> + conn |> put_status(:ok) |> json(%{data: provider.templates}) + + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "provider not found"}) + end + end + + operation(:upload_template, + summary: "Upload solicitation template", + description: "Upload a fillable PDF. Fields are auto-discovered via solicitation_service.", + parameters: [ + provider_id: [in: :path, type: :string, required: true] + ], + request_body: + {"Multipart PDF upload", "multipart/form-data", PS.UploadTemplateRequest, required: true}, + responses: [ + created: {"Template registered", "application/json", PS.UploadTemplateResponse} + ] + ) + + def upload_template(conn, %{"provider_id" => provider_id} = params) do + # %Plug.Upload{} + upload = params["file"] + policy_type = params["policy_type"] + client_type = params["client_type"] + + template_id = Ecto.UUID.generate() + s3_key = "templates/#{provider_id}/#{policy_type}/#{client_type}/#{template_id}.pdf" + + # Upload to S3/MinIO + case S3.upload(upload.path, s3_key) do + :ok -> + # Discover AcroForm fields via solicitation_service + fields = discover_fields(s3_key) + + cmd = %AddProviderTemplate{ + provider_id: provider_id, + template_id: template_id, + client_type: client_type, + policy_type: policy_type, + s3_key: s3_key, + fields: fields + } + + case ProviderService.CommandedApp.dispatch(cmd) do + :ok -> + conn + |> put_status(:created) + |> json(%{template_id: template_id, s3_key: s3_key, fields: fields}) + + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: reason}) + end + + {:error, reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "S3 upload failed: #{reason}"}) + end + end + + defp discover_fields(s3_key) do + url = Application.get_env(:provider_service, :solicitation_service_url) + + case Req.get("#{url}/api/solicitations/templates/fields", params: [s3_key: s3_key]) do + {:ok, %{status: 200, body: %{"fields" => fields}}} -> fields + _ -> [] + end + end + + operation(:activate, + summary: "Activate a template", + parameters: [ + provider_id: [in: :path, type: :string, required: true], + template_id: [in: :path, type: :string, required: true] + ], + responses: [ + ok: {"Template activated", "application/json", PS.ProviderResponse} + ] + ) + + def activate(conn, %{"provider_id" => provider_id, "template_id" => template_id} = params) do + command = %ActivateProviderTemplate{ + provider_id: provider_id, + template_id: template_id, + policy_type: params["policy_type"], + client_type: params["client_type"] + } + + dispatch_and_respond(conn, command, provider_id) + end + + operation(:deactivate, + summary: "Deactivate a template", + parameters: [ + provider_id: [in: :path, type: :string, required: true], + template_id: [in: :path, type: :string, required: true] + ], + responses: [ + ok: {"Template deactivated", "application/json", PS.ProviderResponse} + ] + ) + + def deactivate(conn, %{"provider_id" => provider_id, "template_id" => template_id} = params) do + command = %DeactivateProviderTemplate{ + provider_id: provider_id, + template_id: template_id, + policy_type: params["policy_type"], + client_type: params["client_type"] + } + + dispatch_and_respond(conn, command, provider_id) + end + + operation(:set_default, + summary: "Set default template for a policy type", + parameters: [ + provider_id: [in: :path, type: :string, required: true], + template_id: [in: :path, type: :string, required: true] + ], + responses: [ + ok: {"Default template set", "application/json", PS.ProviderResponse} + ] + ) + + def set_default(conn, %{"provider_id" => provider_id, "template_id" => template_id} = params) do + command = %SetDefaultProviderTemplate{ + provider_id: provider_id, + template_id: template_id, + policy_type: params["policy_type"], + client_type: params["client_type"] + } + + dispatch_and_respond(conn, command, provider_id) + end + + operation(:remove, + summary: "Remove a template", + parameters: [ + provider_id: [in: :path, type: :string, required: true], + template_id: [in: :path, type: :string, required: true] + ], + responses: [ + ok: {"Template removed", "application/json", PS.ProviderResponse} + ] + ) + + def remove(conn, %{"provider_id" => provider_id, "template_id" => template_id} = params) do + command = %RemoveProviderTemplate{ + provider_id: provider_id, + template_id: template_id, + policy_type: params["policy_type"], + client_type: params["client_type"] + } + + dispatch_and_respond(conn, command, provider_id) + end + + defp dispatch_and_respond(conn, command, provider_id) do + case CommandedApp.dispatch(command, consistency: :strong) do + :ok -> + {:ok, provider} = ProviderQueries.get_provider(provider_id) + conn |> put_status(:ok) |> json(%{data: provider_json(provider)}) + + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)}) + end + end + + defp provider_json(p) do + %{ + provider_id: p.provider_id, + name: p.name, + email: p.email, + phone: p.phone, + contact_name: p.contact_name, + ruc: p.ruc, + address: p.address, + active: p.active, + templates: p.templates, + default_templates: p.default_templates + } + end +end diff --git a/lib/provider_service_web/endpoint.ex b/lib/provider_service_web/endpoint.ex new file mode 100644 index 0000000..2b6f2c5 --- /dev/null +++ b/lib/provider_service_web/endpoint.ex @@ -0,0 +1,25 @@ +defmodule ProviderServiceWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :provider_service + + @session_options [ + store: :cookie, + key: "_provider_service_key", + signing_salt: "somesalt", + same_site: "Lax" + ] + + plug(Plug.RequestId) + plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) + + plug(Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + ) + + plug(Plug.MethodOverride) + plug(Plug.Head) + plug(Plug.Session, @session_options) + plug(CORSPlug, origin: ["http://localhost:3000"]) + plug(ProviderServiceWeb.Router) +end diff --git a/lib/provider_service_web/provider_service_web.ex b/lib/provider_service_web/provider_service_web.ex new file mode 100644 index 0000000..5a0ad96 --- /dev/null +++ b/lib/provider_service_web/provider_service_web.ex @@ -0,0 +1,20 @@ +defmodule ProviderServiceWeb do + def controller do + quote do + use Phoenix.Controller, formats: [:json] + import Plug.Conn + end + end + + def router do + quote do + use Phoenix.Router, helpers: false + import Plug.Conn + import Phoenix.Controller + end + end + + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/provider_service_web/router.ex b/lib/provider_service_web/router.ex new file mode 100644 index 0000000..c7a2fe4 --- /dev/null +++ b/lib/provider_service_web/router.ex @@ -0,0 +1,73 @@ +defmodule ProviderServiceWeb.Router do + use Phoenix.Router + + pipeline :api do + plug(:accepts, ["json"]) + plug(OpenApiSpex.Plug.PutApiSpec, module: ProviderServiceWeb.ApiSpec) + end + + scope "/api" do + pipe_through(:api) + + get("/openapi", OpenApiSpex.Plug.RenderSpec, []) + + scope "/v1" do + # Providers + get("/providers", ProviderServiceWeb.ProviderController, :index) + post("/providers", ProviderServiceWeb.ProviderController, :create) + get("/providers/:provider_id", ProviderServiceWeb.ProviderController, :show) + put("/providers/:provider_id", ProviderServiceWeb.ProviderController, :update) + + post( + "/providers/:provider_id/deactivate", + ProviderServiceWeb.ProviderController, + :deactivate + ) + + post( + "/providers/:provider_id/reactivate", + ProviderServiceWeb.ProviderController, + :reactivate + ) + + # Templates + get("/providers/:provider_id/templates", ProviderServiceWeb.TemplateController, :index) + + post( + "/providers/:provider_id/templates", + ProviderServiceWeb.TemplateController, + :upload_template + ) + + post( + "/providers/:provider_id/templates/:template_id/activate", + ProviderServiceWeb.TemplateController, + :activate + ) + + post( + "/providers/:provider_id/templates/:template_id/deactivate", + ProviderServiceWeb.TemplateController, + :deactivate + ) + + post( + "/providers/:provider_id/templates/:template_id/set-default", + ProviderServiceWeb.TemplateController, + :set_default + ) + + delete( + "/providers/:provider_id/templates/:template_id", + ProviderServiceWeb.TemplateController, + :remove + ) + end + end + + if Mix.env() == :dev do + scope "/swaggerui" do + get("/", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi") + end + end +end diff --git a/lib/provider_service_web/schemas/provider.ex b/lib/provider_service_web/schemas/provider.ex new file mode 100644 index 0000000..e4162e1 --- /dev/null +++ b/lib/provider_service_web/schemas/provider.ex @@ -0,0 +1,228 @@ +defmodule ProviderServiceWeb.Schemas.Provider do + alias OpenApiSpex.Schema + + defmodule PaginationMeta do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "PaginationMeta", + type: :object, + properties: %{ + 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 TemplateField do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "TemplateField", + type: :object, + properties: %{ + field: %Schema{type: :string, example: "beneficiary_name"}, + label: %Schema{type: :string, example: "Beneficiary Name"}, + type: %Schema{type: :string, enum: ["string", "date", "number", "select", "boolean"]}, + required: %Schema{type: :boolean}, + options: %Schema{type: :array, items: %Schema{type: :string}, nullable: true} + } + }) + end + + defmodule Template do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Template", + type: :object, + properties: %{ + template_id: %Schema{type: :string, format: :uuid}, + policy_type: %Schema{type: :string, enum: ["car", "life", "fire"]}, + client_type: %Schema{type: :string, enum: ["natural", "juridico"]}, + s3_key: %Schema{type: :string}, + version: %Schema{type: :integer}, + fields: %Schema{type: :array, items: TemplateField}, + active: %Schema{type: :boolean} + } + }) + end + + defmodule RegisterProvider do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "RegisterProvider", + type: :object, + required: [:provider_id, :name], + properties: %{ + provider_id: %Schema{ + type: :string, + pattern: "^[a-zA-Z0-9]+$", + description: "Alphanumeric ID for the provider" + }, + name: %Schema{type: :string, example: "Seguros ABC"}, + email: %Schema{type: :string, format: :email}, + phone: %Schema{type: :string}, + contact_name: %Schema{type: :string}, + ruc: %Schema{type: :string}, + address: %Schema{type: :string} + } + }) + end + + defmodule UpdateProvider do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "UpdateProvider", + type: :object, + properties: %{ + name: %Schema{type: :string}, + email: %Schema{type: :string, format: :email}, + phone: %Schema{type: :string}, + contact_name: %Schema{type: :string}, + ruc: %Schema{type: :string}, + address: %Schema{type: :string} + } + }) + end + + defmodule UploadTemplateRequest do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "UploadTemplateRequest", + type: :object, + required: [:file, :policy_type, :client_type], + properties: %{ + file: %Schema{ + type: :string, + format: :binary, + description: "Fillable PDF (AcroForm)" + }, + policy_type: %Schema{ + type: :string, + enum: ["car", "life", "fire"], + description: "Policy type this template applies to" + }, + client_type: %Schema{ + type: :string, + enum: ["natural", "juridico"], + description: "Client type this template applies to" + } + } + }) + end + + defmodule UploadTemplateResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "UploadTemplateResponse", + type: :object, + properties: %{ + template_id: %Schema{type: :string, format: :uuid}, + s3_key: %Schema{type: :string}, + fields: %Schema{type: :array, items: TemplateField} + } + }) + end + + # templates: %{ policy_type => %{ client_type => [Template] } } + # default_templates: %{ policy_type => %{ client_type => template_id } } + + defmodule ClientTypeTemplates do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ClientTypeTemplates", + type: :object, + description: "Map of client_type (natural | juridico) to list of templates", + properties: %{ + natural: %Schema{type: :array, items: Template, nullable: true}, + juridico: %Schema{type: :array, items: Template, nullable: true} + } + }) + end + + defmodule ClientTypeDefaults do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ClientTypeDefaults", + type: :object, + description: "Map of client_type (natural | juridico) to default template_id", + properties: %{ + natural: %Schema{type: :string, format: :uuid, nullable: true}, + juridico: %Schema{type: :string, format: :uuid, nullable: true} + } + }) + end + + defmodule ProviderData do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ProviderData", + type: :object, + properties: %{ + provider_id: %Schema{type: :string, format: :uuid}, + name: %Schema{type: :string}, + email: %Schema{type: :string}, + phone: %Schema{type: :string}, + contact_name: %Schema{type: :string}, + ruc: %Schema{type: :string}, + address: %Schema{type: :string}, + active: %Schema{type: :boolean}, + templates: %Schema{ + type: :object, + description: "Map of policy_type (car | life | fire) to client_type map of templates", + properties: %{ + car: ClientTypeTemplates, + life: ClientTypeTemplates, + fire: ClientTypeTemplates + } + }, + default_templates: %Schema{ + type: :object, + description: "Map of policy_type to client_type to default template_id", + properties: %{ + car: ClientTypeDefaults, + life: ClientTypeDefaults, + fire: ClientTypeDefaults + } + } + } + }) + end + + defmodule ProviderResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ProviderResponse", + type: :object, + properties: %{ + data: ProviderData + } + }) + end + + defmodule ProviderListResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ProviderListResponse", + type: :object, + properties: %{ + data: %Schema{type: :array, items: ProviderData}, + meta: PaginationMeta + } + }) + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..233f07d --- /dev/null +++ b/mix.exs @@ -0,0 +1,69 @@ +defmodule ProviderService.MixProject do + use Mix.Project + + def project do + [ + app: :provider_service, + version: "0.1.0", + elixir: "~> 1.18", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + def application do + [ + mod: {ProviderService.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + # Phoenix + {:phoenix, "~> 1.7"}, + {:phoenix_ecto, "~> 4.6"}, + {:ecto_sql, "~> 3.12"}, + {:postgrex, ">= 0.0.0"}, + {:bandit, "~> 1.5"}, + {:cors_plug, "~> 3.0"}, + + # Commanded / CQRS + {:commanded, "~> 1.4"}, + {:commanded_eventstore_adapter, "~> 1.4"}, + {:eventstore, "~> 1.4"}, + {:commanded_ecto_projections, "~> 1.4"}, + + # Serialization + {:jason, "~> 1.4"}, + + # OpenAPI + {:open_api_spex, "~> 3.21"}, + + # Pagination + {:flop, "~> 0.26"}, + + # AWS S3 + {:ex_aws, "~> 2.5"}, + {:ex_aws_s3, "~> 2.5"}, + {:hackney, "~> 1.20"}, + {:req, "~> 0.5"}, + {:sweet_xml, "~> 0.7"} + ] + end + + defp aliases do + [ + setup: ["deps.get", "ecto.setup", "event_store.setup"], + "ecto.setup": ["ecto.create", "ecto.migrate"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + "event_store.setup": ["event_store.create", "event_store.init"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..207ca50 --- /dev/null +++ b/mix.lock @@ -0,0 +1,48 @@ +%{ + "backoff": {:hex, :backoff, "1.1.6", "83b72ed2108ba1ee8f7d1c22e0b4a00cfe3593a67dbc792799e8cce9f42f796b", [:rebar3], [], "hexpm", "cf0cfff8995fb20562f822e5cc47d8ccf664c5ecdc26a684cbe85c225f9d7c39"}, + "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, + "commanded": {:hex, :commanded, "1.4.9", "289bc371943cf082f1161b1560563f5451ca176c967670cccd63fc3988fcd225", [:mix], [{:backoff, "~> 1.1", [hex: :backoff, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "a4f49c23041a23687aa10e99f3db7ee3b8ae470bb615b73b9f887b86437263e7"}, + "commanded_ecto_projections": {:hex, :commanded_ecto_projections, "1.4.0", "a1b220577577d5e0aee4c92b2d9bc6de221f9c1ac2ab36932cba15881761332f", [:mix], [{:commanded, "~> 1.4", [hex: :commanded, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8919a6173cd8f30fe2f948c2967f9289c7f5fe4eeca7abc67966bfca31f4aa9f"}, + "commanded_eventstore_adapter": {:hex, :commanded_eventstore_adapter, "1.4.2", "4f2d9d9bd8ef7807a5a4c278b4344adddbbbb4d9c86c693872bc85b944be1fe8", [:mix], [{:commanded, "~> 1.4", [hex: :commanded, repo: "hexpm", optional: false]}, {:eventstore, "~> 1.4", [hex: :eventstore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "26eaa68515e3e73834d769b73bddfea76c3fdcaff085d735c22b82a66ba19b10"}, + "cors_plug": {:hex, :cors_plug, "3.0.3", "7c3ac52b39624bc616db2e937c282f3f623f25f8d550068b6710e58d04a0e330", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3f2d759e8c272ed3835fab2ef11b46bddab8c1ab9528167bd463b6452edf830d"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "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.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [: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", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, + "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"}, + "ex_aws": {:hex, :ex_aws, "2.6.1", "194582c7b09455de8a5ab18a0182e6dd937d53df82be2e63c619d01bddaccdfa", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67842a08c90a1d9a09dbe4ac05754175c7ca253abe4912987c759395d4bd9d26"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.9", "862b7792f2e60d7010e2920d79964e3fab289bc0fd951b0ba8457a3f7f9d1199", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a480d2bb2da64610014021629800e1e9457ca5e4a62f6775bffd963360c2bf90"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "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"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "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"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [: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", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "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"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "telemetry_registry": {:hex, :telemetry_registry, "0.3.2", "701576890320be6428189bff963e865e8f23e0ff3615eade8f78662be0fc003c", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7ed191eb1d115a3034af8e1e35e4e63d5348851d556646d46ca3d1b4e16bab9"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, +} diff --git a/ops/chart/Chart.yaml b/ops/chart/Chart.yaml new file mode 100644 index 0000000..3736438 --- /dev/null +++ b/ops/chart/Chart.yaml @@ -0,0 +1,14 @@ +apiVersion: v2 +name: provider-service +description: Provider 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/ \ No newline at end of file diff --git a/ops/chart/values.yaml b/ops/chart/values.yaml new file mode 100644 index 0000000..750442a --- /dev/null +++ b/ops/chart/values.yaml @@ -0,0 +1,158 @@ +controllers: + main: + enabled: true + type: deployment + replicas: 1 + containers: + main: + image: + repository: gitea.corredorconect.com/software-engineering/provider-service + tag: '{{ $.Chart.AppVersion }}' + env: + LOG_LEVEL: debug + MIX_ENV: prod + PORT: "8080" + PHX_HOST: "0.0.0.0" + PHX_SERVER: "true" + S3_HOST: + value: "dev.s3.corredorconect.com" + S3_BUCKET: + value: "provider-service" + AWS_REGION: + value: "us-east-1" + AWS_ACCESS_KEY_ID: + valueFrom: + secretKeyRef: + name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-s3-credentials' + key: rootAccessKeyId + AWS_SECRET_ACCESS_KEY: + valueFrom: + secretKeyRef: + name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-s3-credentials' + key: rootSecretAccessKey + RELEASE_COOKIE: + valueFrom: + secretKeyRef: + name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-secrets' + key: cookie + SECRET_KEY_BASE: + valueFrom: + secretKeyRef: + name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-secrets' + key: secretKeyBase + DATABASE_URL: + valueFrom: + secretKeyRef: + name: provider-service-cluster-pg-app + key: uri + 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 + + s3-credentials: + enabled: true + apiVersion: external-secrets.io/v1 + kind: ExternalSecret + suffix: s3-credentials + spec: + spec: + refreshInterval: 0s + secretStoreRef: + name: cluster-secrets-store + kind: ClusterSecretStore + target: + name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-s3-credentials' + creationPolicy: Owner + data: + - secretKey: rootAccessKeyId + remoteRef: + key: versitygw/versitygw-external-secret-secrets + property: rootAccessKeyId + - secretKey: rootSecretAccessKey + remoteRef: + key: versitygw/versitygw-external-secret-secrets + property: rootSecretAccessKey + + 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 provider-service" + instances: 1 + bootstrap: + initdb: + database: provider_service + owner: provider_service + storage: + size: 5Gi \ No newline at end of file diff --git a/priv/repo/migrations/20260312182715_create_providers.exs b/priv/repo/migrations/20260312182715_create_providers.exs new file mode 100644 index 0000000..1f393f1 --- /dev/null +++ b/priv/repo/migrations/20260312182715_create_providers.exs @@ -0,0 +1,23 @@ +defmodule ProviderService.Repo.Migrations.CreateProviders do + use Ecto.Migration + + def change do + create table(:providers, primary_key: false) do + add(:provider_id, :string, primary_key: true) + add(:name, :string, null: false) + add(:email, :string) + add(:phone, :string) + add(:contact_name, :string) + add(:ruc, :string) + add(:address, :string) + add(:active, :boolean, default: true, null: false) + add(:templates, :map, default: %{}) + add(:default_templates, :map, default: %{}) + + timestamps(type: :utc_datetime_usec) + end + + create(index(:providers, [:active])) + create(index(:providers, [:name])) + end +end diff --git a/priv/repo/migrations/20260312182740_create_projection_versions.exs b/priv/repo/migrations/20260312182740_create_projection_versions.exs new file mode 100644 index 0000000..ae6a3de --- /dev/null +++ b/priv/repo/migrations/20260312182740_create_projection_versions.exs @@ -0,0 +1,12 @@ +defmodule PolicyService.Repo.Migrations.CreateProjectionVersions do + use Ecto.Migration + + def change do + create table(:projection_versions, primary_key: false) do + add(:projection_name, :text, primary_key: true) + add(:last_seen_event_number, :bigint) + + timestamps(type: :naive_datetime_usec) + end + end +end diff --git a/rel/vm.args.eex b/rel/vm.args.eex new file mode 100644 index 0000000..0f30234 --- /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 \ No newline at end of file diff --git a/test/provider_service_test.exs b/test/provider_service_test.exs new file mode 100644 index 0000000..2bcba6d --- /dev/null +++ b/test/provider_service_test.exs @@ -0,0 +1,8 @@ +defmodule ProviderServiceTest do + use ExUnit.Case + doctest ProviderService + + test "greets the world" do + assert ProviderService.hello() == :world + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()