diff --git a/config/test.exs b/config/test.exs index 41dc029..0e53f97 100644 --- a/config/test.exs +++ b/config/test.exs @@ -29,3 +29,6 @@ config :phoenix, :plug_init_mode, :runtime # Sort query params output of verified routes for robust url comparisons config :phoenix, sort_verified_routes_query_params: true + +config :customer_service, CustomerService.EventStore, + serializer: Commanded.Serialization.JsonSerializer diff --git a/lib/customer_service/aggregates/corporate_customer.ex b/lib/customer_service/aggregates/corporate_customer.ex index 21dd567..f7a2a72 100644 --- a/lib/customer_service/aggregates/corporate_customer.ex +++ b/lib/customer_service/aggregates/corporate_customer.ex @@ -33,6 +33,21 @@ defmodule CustomerService.Aggregates.CorporateCustomer do } end + @impl Aggregate + def execute(%CorporateCustomer{id: _id}, %Commands.UpdateCorporateCustomer{} = cmd) do + %Events.CorporateCustomerUpdated{ + 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{ @@ -48,4 +63,19 @@ defmodule CustomerService.Aggregates.CorporateCustomer do address: e.address } end + + @impl Aggregate + def apply(%CorporateCustomer{} = c, %Events.CorporateCustomerUpdated{} = e) do + %CorporateCustomer{ + c + | legal_name: e.legal_name || c.legal_name, + commercial_name: e.commercial_name || c.commercial_name, + ruc: e.ruc || c.ruc, + legal_rep_name: e.legal_rep_name || c.legal_rep_name, + legal_rep_document_id: e.legal_rep_document_id || c.legal_rep_document_id, + email: e.email || c.email, + phone: e.phone || c.phone, + address: e.address || c.address + } + end end diff --git a/lib/customer_service/aggregates/customer.ex b/lib/customer_service/aggregates/customer.ex index 82704a9..ba1a53e 100644 --- a/lib/customer_service/aggregates/customer.ex +++ b/lib/customer_service/aggregates/customer.ex @@ -32,6 +32,21 @@ defmodule CustomerService.Aggregates.Customer do } end + @impl Aggregate + def execute(%Customer{id: _id}, %Commands.UpdateCustomer{} = cmd) do + %Events.CustomerUpdated{ + id: cmd.id, + first_name: cmd.first_name, + last_name: cmd.last_name, + birth_date: cmd.birth_date, + gender: cmd.gender, + email: cmd.email, + phone: cmd.phone, + document_id: cmd.document_id, + address: cmd.address + } + end + @impl Aggregate def apply(%Customer{} = c, %Events.CustomerCreated{} = e) do %__MODULE__{ @@ -47,4 +62,19 @@ defmodule CustomerService.Aggregates.Customer do document_id: e.document_id } end + + @impl Aggregate + def apply(%Customer{} = c, %Events.CustomerUpdated{} = e) do + %__MODULE__{ + c + | first_name: e.first_name || c.first_name, + last_name: e.last_name || c.last_name, + birth_date: e.birth_date || c.birth_date, + gender: e.gender || c.gender, + email: e.email || c.email, + phone: e.phone || c.phone, + address: e.address || c.address, + document_id: e.document_id || c.document_id + } + end end diff --git a/lib/customer_service/aggregates/quick_lead.ex b/lib/customer_service/aggregates/quick_lead.ex new file mode 100644 index 0000000..ca1d5ac --- /dev/null +++ b/lib/customer_service/aggregates/quick_lead.ex @@ -0,0 +1,140 @@ +defmodule CustomerService.Aggregates.QuickLead do + defstruct [ + :id, + :name, + :email, + :phone, + :company_name, + :status, + :priority, + :source, + :notes, + :assigned_to, + :estimated_value, + :expected_close_date, + :status_history + ] + + alias __MODULE__ + alias Commanded.Aggregates.Aggregate + alias CustomerService.Commands + alias CustomerService.Events + + @behaviour Aggregate + + @valid_statuses ~w(new contacted qualified proposal negotiation converted lost)a + + @impl Aggregate + def execute(%QuickLead{id: nil}, %Commands.CreateQuickLead{} = cmd) do + status = cmd.status || :new + priority = cmd.priority || :medium + source = cmd.source || :other + + %Events.QuickLeadCreated{ + id: cmd.id, + name: cmd.name, + email: cmd.email, + phone: cmd.phone, + company_name: cmd.company_name, + status: status, + priority: priority, + source: source, + notes: cmd.notes, + assigned_to: cmd.assigned_to, + estimated_value: cmd.estimated_value, + expected_close_date: cmd.expected_close_date + } + end + + @impl Aggregate + def execute(%QuickLead{id: _id}, %Commands.UpdateQuickLead{} = cmd) do + %Events.QuickLeadUpdated{ + id: cmd.id, + name: cmd.name, + email: cmd.email, + phone: cmd.phone, + company_name: cmd.company_name, + notes: cmd.notes, + assigned_to: cmd.assigned_to, + estimated_value: cmd.estimated_value, + expected_close_date: cmd.expected_close_date + } + end + + @impl Aggregate + def execute(%QuickLead{id: _id, status: current_status}, %Commands.UpdateLeadStatus{} = cmd) do + new_status = cmd.status + + if valid_status_transition?(current_status, new_status) do + %Events.LeadStatusUpdated{ + id: cmd.id, + status: new_status, + previous_status: current_status, + updated_at: DateTime.utc_now() + } + else + {:error, :invalid_status_transition} + end + end + + @impl Aggregate + def apply(%QuickLead{id: nil}, %Events.QuickLeadCreated{} = e) do + %__MODULE__{ + id: e.id, + name: e.name, + email: e.email, + phone: e.phone, + company_name: e.company_name, + status: e.status, + priority: e.priority, + source: e.source, + notes: e.notes, + assigned_to: e.assigned_to, + estimated_value: e.estimated_value, + expected_close_date: e.expected_close_date, + status_history: [ + %{ + status: e.status, + updated_at: DateTime.utc_now(), + notes: "Lead created" + } + ] + } + end + + @impl Aggregate + def apply(%QuickLead{} = lead, %Events.QuickLeadUpdated{} = e) do + %__MODULE__{ + lead + | name: e.name || lead.name, + email: e.email || lead.email, + phone: e.phone || lead.phone, + company_name: e.company_name || lead.company_name, + notes: e.notes || lead.notes, + assigned_to: e.assigned_to || lead.assigned_to, + estimated_value: e.estimated_value || lead.estimated_value, + expected_close_date: e.expected_close_date || lead.expected_close_date + } + end + + @impl Aggregate + def apply(%QuickLead{} = lead, %Events.LeadStatusUpdated{} = e) do + %__MODULE__{ + lead + | status: e.status, + status_history: [ + %{ + status: e.status, + previous_status: e.previous_status, + updated_at: e.updated_at + } + | lead.status_history + ] + } + end + + defp valid_status_transition?(current_status, new_status) do + current_status in @valid_statuses and new_status in @valid_statuses and + current_status != new_status + end +end diff --git a/lib/customer_service/application.ex b/lib/customer_service/application.ex index 83dd94c..92b732b 100644 --- a/lib/customer_service/application.ex +++ b/lib/customer_service/application.ex @@ -11,6 +11,7 @@ defmodule CustomerService.Application do CustomerService.CommandedApp, CustomerService.Repo, CustomerService.Projectors.Customer, + CustomerService.Projectors.QuickLead, CustomerServiceWeb.Telemetry, {DNSCluster, query: Application.get_env(:customer_service, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: CustomerService.PubSub}, diff --git a/lib/customer_service/commanded_app.ex b/lib/customer_service/commanded_app.ex index 52428fb..d97cf00 100644 --- a/lib/customer_service/commanded_app.ex +++ b/lib/customer_service/commanded_app.ex @@ -4,12 +4,20 @@ defmodule CustomerService.Router do alias CustomerService.Aggregates identify(Aggregates.Customer, by: :id) - dispatch([Commands.CreateCustomer], to: Aggregates.Customer) + dispatch([Commands.CreateCustomer, Commands.UpdateCustomer], to: Aggregates.Customer) - dispatch(Commands.CreateCorporateCustomer, + dispatch( + [Commands.CreateCorporateCustomer, Commands.UpdateCorporateCustomer], to: Aggregates.CorporateCustomer, identity: :id ) + + identify(Aggregates.QuickLead, by: :id) + + dispatch( + [Commands.CreateQuickLead, Commands.UpdateQuickLead, Commands.UpdateLeadStatus], + to: Aggregates.QuickLead + ) end defmodule CustomerService.CommandedApp do diff --git a/lib/customer_service/commands.ex b/lib/customer_service/commands.ex index 2de17a1..d8979c3 100644 --- a/lib/customer_service/commands.ex +++ b/lib/customer_service/commands.ex @@ -25,3 +25,66 @@ defmodule CustomerService.Commands.CreateCorporateCustomer do :address ] end + +defmodule CustomerService.Commands.UpdateCustomer do + defstruct [ + :id, + :first_name, + :last_name, + :birth_date, + :gender, + :email, + :phone, + :address, + :document_id + ] +end + +defmodule CustomerService.Commands.UpdateCorporateCustomer do + defstruct [ + :id, + :legal_name, + :commercial_name, + :ruc, + :legal_rep_name, + :legal_rep_document_id, + :email, + :phone, + :address + ] +end + +defmodule CustomerService.Commands.CreateQuickLead do + defstruct [ + :id, + :name, + :email, + :phone, + :company_name, + :status, + :priority, + :source, + :notes, + :assigned_to, + :estimated_value, + :expected_close_date + ] +end + +defmodule CustomerService.Commands.UpdateQuickLead do + defstruct [ + :id, + :name, + :email, + :phone, + :company_name, + :notes, + :assigned_to, + :estimated_value, + :expected_close_date + ] +end + +defmodule CustomerService.Commands.UpdateLeadStatus do + defstruct [:id, :status] +end diff --git a/lib/customer_service/events.ex b/lib/customer_service/events.ex index fa8f8f2..e5cadac 100644 --- a/lib/customer_service/events.ex +++ b/lib/customer_service/events.ex @@ -27,3 +27,71 @@ defmodule CustomerService.Events.CorporateCustomerCreated do :address ] end + +defmodule CustomerService.Events.CustomerUpdated do + @derive Jason.Encoder + defstruct [ + :id, + :first_name, + :last_name, + :birth_date, + :gender, + :email, + :phone, + :address, + :document_id + ] +end + +defmodule CustomerService.Events.CorporateCustomerUpdated do + @derive Jason.Encoder + defstruct [ + :id, + :legal_name, + :commercial_name, + :ruc, + :legal_rep_name, + :legal_rep_document_id, + :email, + :phone, + :address + ] +end + +defmodule CustomerService.Events.QuickLeadCreated do + @derive Jason.Encoder + defstruct [ + :id, + :name, + :email, + :phone, + :company_name, + :status, + :priority, + :source, + :notes, + :assigned_to, + :estimated_value, + :expected_close_date + ] +end + +defmodule CustomerService.Events.QuickLeadUpdated do + @derive Jason.Encoder + defstruct [ + :id, + :name, + :email, + :phone, + :company_name, + :notes, + :assigned_to, + :estimated_value, + :expected_close_date + ] +end + +defmodule CustomerService.Events.LeadStatusUpdated do + @derive Jason.Encoder + defstruct [:id, :status, :previous_status, :updated_at] +end diff --git a/lib/customer_service/lead/filters.ex b/lib/customer_service/lead/filters.ex new file mode 100644 index 0000000..85dcf8f --- /dev/null +++ b/lib/customer_service/lead/filters.ex @@ -0,0 +1,17 @@ +defmodule CustomerService.Lead.Filters do + import Ecto.Query + + def search(query, %Flop.Filter{value: value}, _opts) do + term = "%#{value}%" + + where( + query, + [l], + ilike(l.name, ^term) or + ilike(l.email, ^term) or + ilike(l.phone, ^term) or + ilike(l.company_name, ^term) or + ilike(l.assigned_to, ^term) + ) + end +end diff --git a/lib/customer_service/lead/queries.ex b/lib/customer_service/lead/queries.ex new file mode 100644 index 0000000..1e7236b --- /dev/null +++ b/lib/customer_service/lead/queries.ex @@ -0,0 +1,15 @@ +defmodule CustomerService.Lead.Queries do + alias CustomerService.Projections.QuickLead + alias CustomerService.Repo + + def list_leads(params \\ %{}) do + Flop.validate_and_run(QuickLead, params, for: QuickLead) + end + + def get_lead(id) do + case Repo.get(QuickLead, id) do + nil -> {:error, :not_found} + lead -> {:ok, lead} + end + end +end diff --git a/lib/customer_service/projections/quick_lead.ex b/lib/customer_service/projections/quick_lead.ex new file mode 100644 index 0000000..c4f088b --- /dev/null +++ b/lib/customer_service/projections/quick_lead.ex @@ -0,0 +1,57 @@ +defmodule CustomerService.Projections.QuickLead do + use Ecto.Schema + + @derive {Jason.Encoder, + only: [ + :id, + :name, + :email, + :phone, + :company_name, + :status, + :priority, + :source, + :notes, + :assigned_to, + :estimated_value, + :expected_close_date, + :status_history, + :inserted_at, + :updated_at + ]} + + @derive { + Flop.Schema, + filterable: [:status, :priority, :source, :assigned_to, :search], + sortable: [:name, :company_name, :status, :priority, :inserted_at], + default_limit: 20, + max_limit: 100, + custom_fields: [ + search: [ + filter: {CustomerService.Lead.Filters, :search, []}, + ecto_type: :string, + operators: [:==] + ] + ] + } + + @primary_key {:id, :binary_id, autogenerate: false} + @timestamps_opts [type: :utc_datetime_usec] + + schema "quick_leads" do + field :name, :string + field :email, :string + field :phone, :string + field :company_name, :string + field :status, :string + field :priority, :string + field :source, :string + field :notes, :string + field :assigned_to, :string + field :estimated_value, :decimal + field :expected_close_date, :date + field :status_history, :map + + timestamps() + end +end diff --git a/lib/customer_service/projectors/customer.ex b/lib/customer_service/projectors/customer.ex index 39ad102..f10aae5 100644 --- a/lib/customer_service/projectors/customer.ex +++ b/lib/customer_service/projectors/customer.ex @@ -47,4 +47,34 @@ defmodule CustomerService.Projectors.Customer do address: e.address }) end) + + project(%Events.CustomerUpdated{} = e, _meta, fn multi -> + Ecto.Multi.update_all(multi, :customer, from(c in Customer, where: c.id == ^e.id), + set: [ + first_name: e.first_name, + last_name: e.last_name, + birth_date: parse_date(e.birth_date), + gender: e.gender, + email: e.email, + phone: e.phone, + address: e.address, + document_id: e.document_id + ] + ) + end) + + project(%Events.CorporateCustomerUpdated{} = e, _meta, fn multi -> + Ecto.Multi.update_all(multi, :customer, from(c in Customer, where: c.id == ^e.id), + set: [ + 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/projectors/quick_lead.ex b/lib/customer_service/projectors/quick_lead.ex new file mode 100644 index 0000000..047f1eb --- /dev/null +++ b/lib/customer_service/projectors/quick_lead.ex @@ -0,0 +1,67 @@ +defmodule CustomerService.Projectors.QuickLead do + use Commanded.Projections.Ecto, + application: CustomerService.CommandedApp, + repo: CustomerService.Repo, + name: "CustomerService.Projectors.QuickLead", + consistency: :strong + + alias CustomerService.Events + alias CustomerService.Projections.QuickLead + + project(%Events.QuickLeadCreated{} = event, fn multi -> + Ecto.Multi.insert(multi, :quick_lead, %QuickLead{ + id: event.id, + name: event.name, + email: event.email, + phone: event.phone, + company_name: event.company_name, + status: to_string(event.status), + priority: to_string(event.priority), + source: to_string(event.source), + notes: event.notes, + assigned_to: event.assigned_to, + estimated_value: event.estimated_value, + expected_close_date: parse_date(event.expected_close_date), + status_history: [ + %{ + status: to_string(event.status), + updated_at: DateTime.utc_now(), + notes: "Lead created" + } + ] + }) + end) + + project(%Events.QuickLeadUpdated{} = event, _meta, fn multi -> + Ecto.Multi.update_all(multi, :quick_lead, from(q in QuickLead, where: q.id == ^event.id), + set: [ + name: event.name, + email: event.email, + phone: event.phone, + company_name: event.company_name, + notes: event.notes, + assigned_to: event.assigned_to, + estimated_value: event.estimated_value, + expected_close_date: parse_date(event.expected_close_date) + ] + ) + end) + + project(%Events.LeadStatusUpdated{} = event, _meta, fn multi -> + Ecto.Multi.update_all(multi, :quick_lead, from(q in QuickLead, where: q.id == ^event.id), + set: [ + status: to_string(event.status) + ] + ) + 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 +end diff --git a/lib/customer_service_web/controllers/customer.ex b/lib/customer_service_web/controllers/customer.ex index c0d7574..24f0985 100644 --- a/lib/customer_service_web/controllers/customer.ex +++ b/lib/customer_service_web/controllers/customer.ex @@ -2,19 +2,25 @@ defmodule CustomerServiceWeb.CustomerController do use CustomerServiceWeb, :controller use OpenApiSpex.ControllerSpecs - alias CustomerService.Commands.{CreateCustomer, CreateCorporateCustomer} + alias CustomerService.Commands.{ + CreateCustomer, + CreateCorporateCustomer, + UpdateCustomer, + UpdateCorporateCustomer + } + alias CustomerService.Customer.Queries, as: CustomerQueries alias CustomerServiceWeb.Schemas.Customer, as: CustomerSchemas + alias CustomerServiceWeb.QueryHelpers 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] - ], + parameters: + QueryHelpers.flop([:customer_type, :email, :phone, :document_id, :ruc, :search], [ + :last_name, + :legal_name, + :inserted_at + ]), responses: [ ok: {"Customer list", "application/json", CustomerSchemas.CustomerListResponse} ] @@ -119,6 +125,63 @@ defmodule CustomerServiceWeb.CustomerController do dispatch_and_return(conn, command, customer_id) end + operation(:update, + summary: "Update individual customer", + parameters: [ + id: [in: :path, type: :string, required: true, description: "Customer ID"] + ], + request_body: + {"Customer data", "application/json", CustomerSchemas.UpdateCustomer, required: true}, + responses: [ + ok: {"Customer updated", "application/json", CustomerSchemas.CustomerResponse} + ] + ) + + def update(conn, %{"id" => id} = params) do + command = %UpdateCustomer{ + id: 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"], + address: params["address"] + } + + dispatch_and_return(conn, command, id) + end + + operation(:update_corporate, + summary: "Update corporate customer", + parameters: [ + id: [in: :path, type: :string, required: true, description: "Customer ID"] + ], + request_body: + {"Corporate customer data", "application/json", CustomerSchemas.UpdateCorporateCustomer, + required: true}, + responses: [ + ok: {"Corporate customer updated", "application/json", CustomerSchemas.CustomerResponse} + ] + ) + + def update_corporate(conn, %{"id" => id} = params) do + command = %UpdateCorporateCustomer{ + id: 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, id) + end + # --------------------------------------------------------------------------- # Private # --------------------------------------------------------------------------- diff --git a/lib/customer_service_web/controllers/lead.ex b/lib/customer_service_web/controllers/lead.ex new file mode 100644 index 0000000..75bb89f --- /dev/null +++ b/lib/customer_service_web/controllers/lead.ex @@ -0,0 +1,202 @@ +defmodule CustomerServiceWeb.LeadController do + use CustomerServiceWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CustomerService.Commands.{CreateQuickLead, UpdateQuickLead, UpdateLeadStatus} + alias CustomerService.Lead.Queries, as: LeadQueries + alias CustomerServiceWeb.Schemas.Lead, as: LeadSchemas + alias CustomerServiceWeb.QueryHelpers + + operation(:index, + summary: "List leads", + parameters: + QueryHelpers.flop([:status, :priority, :source, :assigned_to, :search], [ + :name, + :company_name, + :status, + :priority, + :inserted_at + ]), + responses: [ + ok: {"Lead list", "application/json", LeadSchemas.LeadListResponse} + ] + ) + + def index(conn, params) do + case LeadQueries.list_leads(params) do + {:ok, {leads, meta}} -> + conn + |> put_status(:ok) + |> json(%{ + data: Enum.map(leads, &lead_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 lead", + parameters: [ + id: [in: :path, type: :string, required: true, description: "Lead ID"] + ], + responses: [ + ok: {"Lead", "application/json", LeadSchemas.LeadResponse}, + not_found: {"Not found", "application/json", %OpenApiSpex.Schema{type: :object}} + ] + ) + + def show(conn, %{"id" => id}) do + case LeadQueries.get_lead(id) do + {:ok, lead} -> + conn |> put_status(:ok) |> json(%{data: lead_json(lead)}) + + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "not found"}) + end + end + + operation(:create, + summary: "Create quick lead", + request_body: {"Lead data", "application/json", LeadSchemas.CreateQuickLead, required: true}, + responses: [ + ok: {"Lead created", "application/json", LeadSchemas.LeadResponse} + ] + ) + + def create(conn, params) do + lead_id = Ecto.UUID.generate() + + command = %CreateQuickLead{ + id: lead_id, + name: params["name"], + email: params["email"], + phone: params["phone"], + company_name: params["company_name"], + status: params["status"] || :new, + priority: params["priority"] || :medium, + source: params["source"] || :other, + notes: params["notes"], + assigned_to: params["assigned_to"], + estimated_value: parse_decimal(params["estimated_value"]), + expected_close_date: parse_date(params["expected_close_date"]) + } + + dispatch_and_return(conn, command, lead_id) + end + + operation(:update, + summary: "Update quick lead", + parameters: [ + id: [in: :path, type: :string, required: true, description: "Lead ID"] + ], + request_body: {"Lead data", "application/json", LeadSchemas.UpdateQuickLead, required: true}, + responses: [ + ok: {"Lead updated", "application/json", LeadSchemas.LeadResponse} + ] + ) + + def update(conn, %{"id" => id} = params) do + command = %UpdateQuickLead{ + id: id, + name: params["name"], + email: params["email"], + phone: params["phone"], + company_name: params["company_name"], + notes: params["notes"], + assigned_to: params["assigned_to"], + estimated_value: parse_decimal(params["estimated_value"]), + expected_close_date: parse_date(params["expected_close_date"]) + } + + dispatch_and_return(conn, command, id) + end + + operation(:update_status, + summary: "Update lead status", + parameters: [ + id: [in: :path, type: :string, required: true, description: "Lead ID"] + ], + request_body: + {"Status update", "application/json", LeadSchemas.UpdateLeadStatus, required: true}, + responses: [ + ok: {"Lead status updated", "application/json", LeadSchemas.LeadResponse} + ] + ) + + def update_status(conn, %{"id" => id} = params) do + command = %UpdateLeadStatus{ + id: id, + status: String.to_existing_atom(params["status"]) + } + + dispatch_and_return(conn, command, id) + end + + defp dispatch_and_return(conn, command, lead_id) do + case CustomerService.CommandedApp.dispatch(command, consistency: :strong) do + :ok -> + case LeadQueries.get_lead(lead_id) do + {:ok, lead} -> + conn |> put_status(:ok) |> json(%{data: lead_json(lead)}) + + {:error, :not_found} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "lead created but not found in projection"}) + end + + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)}) + end + end + + defp parse_date(nil), do: nil + + defp parse_date(str) do + case Date.from_iso8601(str) do + {:ok, date} -> date + _ -> nil + end + end + + defp parse_decimal(nil), do: nil + + defp parse_decimal(str) when is_binary(str) do + case Decimal.parse(str) do + {decimal, ""} -> decimal + _ -> nil + end + end + + defp lead_json(lead) do + %{ + id: lead.id, + name: lead.name, + email: lead.email, + phone: lead.phone, + company_name: lead.company_name, + status: lead.status, + priority: lead.priority, + source: lead.source, + notes: lead.notes, + assigned_to: lead.assigned_to, + estimated_value: lead.estimated_value, + expected_close_date: lead.expected_close_date, + status_history: lead.status_history, + inserted_at: lead.inserted_at, + updated_at: lead.updated_at + } + end +end diff --git a/lib/customer_service_web/query_helpers.ex b/lib/customer_service_web/query_helpers.ex new file mode 100644 index 0000000..dd5fbc1 --- /dev/null +++ b/lib/customer_service_web/query_helpers.ex @@ -0,0 +1,34 @@ +defmodule CustomerServiceWeb.QueryHelpers do + @moduledoc false + + alias OpenApiSpex.Schema + + @filter_count 3 + + def flop(filter_fields, order_fields, other \\ []) do + [ + page: [in: :query, schema: %Schema{type: :number, default: 1}], + page_size: [in: :query, schema: %Schema{type: :number, default: 20}], + order_by: [ + in: :query, + schema: %Schema{type: :array, items: %Schema{type: :string, enum: order_fields}} + ], + order_directions: [ + in: :query, + schema: %Schema{type: :array, items: %Schema{type: :string, enum: ["asc", "desc"]}} + ] + ] ++ build_filter_params(filter_fields) ++ other + end + + defp build_filter_params(fields) do + for i <- 0..(@filter_count - 1) do + [ + {:"filters[#{i}][field]", [in: :query, schema: %Schema{type: :string, enum: fields}]}, + {:"filters[#{i}][op]", + [in: :query, schema: %Schema{type: :string, enum: Flop.Filter.allowed_operators(:all)}]}, + {:"filters[#{i}][value]", [in: :query, schema: %Schema{type: :string}]} + ] + end + |> List.flatten() + end +end diff --git a/lib/customer_service_web/router.ex b/lib/customer_service_web/router.ex index f15edef..e7ff29f 100644 --- a/lib/customer_service_web/router.ex +++ b/lib/customer_service_web/router.ex @@ -1,6 +1,6 @@ defmodule CustomerServiceWeb.Router do use CustomerServiceWeb, :router - alias CustomerServiceWeb.CustomerController + alias CustomerServiceWeb.{CustomerController, LeadController} pipeline :api do plug :accepts, ["json"] @@ -21,6 +21,14 @@ defmodule CustomerServiceWeb.Router do post "/customers/corporate", CustomerController, :create_corporate get "/customers", CustomerController, :index get "/customers/:id", CustomerController, :show + put "/customers/individual/:id", CustomerController, :update + put "/customers/corporate/:id", CustomerController, :update_corporate + + post "/leads", LeadController, :create + get "/leads", LeadController, :index + get "/leads/:id", LeadController, :show + put "/leads/:id", LeadController, :update + put "/leads/:id/status", LeadController, :update_status end end diff --git a/lib/customer_service_web/schemas/customer.ex b/lib/customer_service_web/schemas/customer.ex index f8963a5..8808279 100644 --- a/lib/customer_service_web/schemas/customer.ex +++ b/lib/customer_service_web/schemas/customer.ex @@ -114,6 +114,44 @@ defmodule CustomerServiceWeb.Schemas.Customer do }) end + defmodule UpdateCustomer do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "UpdateCustomer", + type: :object, + 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}, + address: %Schema{type: :string} + } + }) + end + + defmodule UpdateCorporateCustomer do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "UpdateCorporateCustomer", + type: :object, + 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}, + address: %Schema{type: :string} + } + }) + end + defmodule CustomerListResponse do require OpenApiSpex diff --git a/lib/customer_service_web/schemas/lead.ex b/lib/customer_service_web/schemas/lead.ex new file mode 100644 index 0000000..03ed338 --- /dev/null +++ b/lib/customer_service_web/schemas/lead.ex @@ -0,0 +1,141 @@ +defmodule CustomerServiceWeb.Schemas.Lead 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 LeadData do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "QuickLead", + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + name: %Schema{type: :string}, + email: %Schema{type: :string, format: :email}, + phone: %Schema{type: :string}, + company_name: %Schema{type: :string}, + status: %Schema{ + type: :string, + enum: ["new", "contacted", "qualified", "proposal", "negotiation", "converted", "lost"] + }, + priority: %Schema{type: :string, enum: ["low", "medium", "high"]}, + source: %Schema{ + type: :string, + enum: ["website", "referral", "social_media", "cold_call", "email_campaign", "other"] + }, + notes: %Schema{type: :string}, + assigned_to: %Schema{type: :string}, + estimated_value: %Schema{type: :string}, + expected_close_date: %Schema{type: :string, format: :date}, + status_history: %Schema{type: :array, items: %Schema{type: :object}}, + inserted_at: %Schema{type: :string, format: :"date-time"}, + updated_at: %Schema{type: :string, format: :"date-time"} + } + }) + end + + defmodule CreateQuickLead do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "CreateQuickLead", + type: :object, + required: [:name], + properties: %{ + name: %Schema{type: :string}, + email: %Schema{type: :string, format: :email}, + phone: %Schema{type: :string}, + company_name: %Schema{type: :string}, + status: %Schema{ + type: :string, + enum: ["new", "contacted", "qualified", "proposal", "negotiation", "converted", "lost"] + }, + priority: %Schema{type: :string, enum: ["low", "medium", "high"]}, + source: %Schema{ + type: :string, + enum: ["website", "referral", "social_media", "cold_call", "email_campaign", "other"] + }, + notes: %Schema{type: :string}, + assigned_to: %Schema{type: :string}, + estimated_value: %Schema{type: :string}, + expected_close_date: %Schema{type: :string, format: :date} + } + }) + end + + defmodule UpdateQuickLead do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "UpdateQuickLead", + type: :object, + properties: %{ + name: %Schema{type: :string}, + email: %Schema{type: :string, format: :email}, + phone: %Schema{type: :string}, + company_name: %Schema{type: :string}, + notes: %Schema{type: :string}, + assigned_to: %Schema{type: :string}, + estimated_value: %Schema{type: :string}, + expected_close_date: %Schema{type: :string, format: :date} + } + }) + end + + defmodule UpdateLeadStatus do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "UpdateLeadStatus", + type: :object, + required: [:status], + properties: %{ + status: %Schema{ + type: :string, + enum: ["new", "contacted", "qualified", "proposal", "negotiation", "converted", "lost"] + } + } + }) + end + + defmodule LeadListResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "LeadListResponse", + type: :object, + properties: %{ + data: %Schema{type: :array, items: LeadData}, + meta: PaginationMeta + } + }) + end + + defmodule LeadResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "LeadResponse", + type: :object, + properties: %{ + data: LeadData + } + }) + end +end diff --git a/priv/repo/migrations/20260430210538_add_quick_leads_table.exs b/priv/repo/migrations/20260430210538_add_quick_leads_table.exs new file mode 100644 index 0000000..45234dc --- /dev/null +++ b/priv/repo/migrations/20260430210538_add_quick_leads_table.exs @@ -0,0 +1,28 @@ +defmodule CustomerService.Repo.Migrations.AddQuickLeadsTable do + use Ecto.Migration + + def change do + create table(:quick_leads, primary_key: false) do + add :id, :uuid, primary_key: true + add :name, :string + add :email, :string + add :phone, :string + add :company_name, :string + add :status, :string, null: false, default: "new" + add :priority, :string, null: false, default: "medium" + add :source, :string, null: false, default: "other" + add :notes, :string + add :assigned_to, :string + add :estimated_value, :decimal + add :expected_close_date, :date + add :status_history, :map + timestamps() + end + + create index(:quick_leads, [:status]) + create index(:quick_leads, [:priority]) + create index(:quick_leads, [:source]) + create index(:quick_leads, [:assigned_to]) + create index(:quick_leads, [:email]) + end +end