defmodule TarakanWeb.BountyLive.New do @moduledoc """ Post a new contract (a bounty internally). This route lives in the public live session so `/bounties/:public_id` can stay public; it enforces authentication in mount and redirects anonymous visitors to the login page. Targets are picked, not typed: repositories come from a live search and infestations from a select, so `target_ref` always holds a reference the market can resolve. `?target_type=&target_ref=` prefills the form, which is how repository and infestation pages link into it. """ use TarakanWeb, :live_view alias Tarakan.Credits alias Tarakan.Infestations alias Tarakan.Market alias Tarakan.Repositories alias Tarakan.Repositories.Repository @target_types [ {"Repository", "repository"}, {"Infestation", "infestation"}, {"Finding", "finding"} ] @fundings [{"Credits", "credits"}, {"Card (fiat escrow)", "fiat"}] @search_limit 8 @infestation_limit 50 @impl true def mount(params, _session, socket) do scope = socket.assigns.current_scope if scope && scope.account do form_params = prefill(params) {:ok, socket |> assign(:page_title, "Post a contract") |> assign(:target_types, @target_types) |> assign(:fundings, @fundings) |> assign(:credit_balance, Credits.balance(scope.account)) |> assign(:infestation_options, infestation_options()) |> assign(:repo_results, []) |> assign_form(form_params)} else {:ok, socket |> put_flash(:error, "You must log in to access this page.") |> redirect(to: ~p"/accounts/log-in")} end end defp prefill(params) do %{ "target_type" => present_or(params["target_type"], "repository"), "target_ref" => present_or(params["target_ref"], ""), "funding" => present_or(params["funding"], "credits") } end defp present_or(value, default) when is_binary(value) do if String.trim(value) == "", do: default, else: value end defp present_or(_value, default), do: default @impl true def handle_event("validate", %{"bounty" => params}, socket) do {:noreply, socket |> assign_form(params) |> search_repositories(params)} end def handle_event("pick_repository", %{"ref" => ref}, socket) do params = socket.assigns.form.params |> Map.put("target_ref", ref) |> Map.put("repo_query", "") {:noreply, socket |> assign_form(params) |> assign(:repo_results, [])} end def handle_event("clear_repository", _params, socket) do {:noreply, socket |> assign_form(Map.put(socket.assigns.form.params, "target_ref", "")) |> assign(:repo_results, [])} end def handle_event("create", %{"bounty" => params}, socket) do attrs = build_attrs(params) case Market.create_bounty(socket.assigns.current_scope, attrs) do {:ok, bounty} -> {:noreply, socket |> put_flash(:info, "Contract posted - credits are held in escrow until settlement.") |> push_navigate(to: ~p"/bounties/#{bounty.public_id}")} {:ok, _bounty, checkout_url} -> {:noreply, redirect(socket, external: checkout_url)} {:error, reason} -> {:noreply, put_flash(socket, :error, create_error_message(reason))} end end # The repository search runs only while the repository target is selected and # nothing has been picked yet - once a ref is set the results list is noise. defp search_repositories(socket, params) do query = params["repo_query"] || "" results = if params["target_type"] == "repository" and blank?(params["target_ref"]) do Repositories.search_repositories(query, @search_limit) else [] end assign(socket, :repo_results, results) end defp assign_form(socket, params) do params = Map.put_new(params, "target_type", "repository") socket |> assign(:form, to_form(params, as: :bounty)) |> assign(:target_type, params["target_type"]) |> assign(:funding, if(params["funding"] == "fiat", do: "fiat", else: "credits")) |> assign(:selected_repository, selected_repository(params)) end defp selected_repository(%{"target_type" => "repository", "target_ref" => ref}) when is_binary(ref) do if String.trim(ref) == "", do: nil, else: ref end defp selected_repository(_params), do: nil defp blank?(nil), do: true defp blank?(value) when is_binary(value), do: String.trim(value) == "" defp infestation_options do Infestations.list_infestations(limit: @infestation_limit) |> Enum.map(fn infestation -> {"#{infestation.title} (#{infestation.repo_count} repos)", infestation.pattern_key} end) end # Hosted repositories are addressed without a host; remote ones need theirs. defp repository_ref(%Repository{} = repository) do if Repository.hosted?(repository) do "#{repository.owner}/#{repository.name}" else "#{repository.host}/#{repository.owner}/#{repository.name}" end end defp build_attrs(params) do funding = if params["funding"] == "fiat", do: "fiat", else: "credits" base = %{ "target_type" => params["target_type"], "target_ref" => params["target_ref"], "title" => params["title"], "description" => params["description"], "funding" => funding, "expires_days" => params["expires_days"] } case funding do "fiat" -> Map.put(base, "amount_cents", dollars_to_cents(params["amount"])) "credits" -> Map.put(base, "credit_amount", parse_integer(params["amount"])) end end defp dollars_to_cents(nil), do: nil defp dollars_to_cents(value) when is_binary(value) do case Float.parse(String.trim(value)) do {dollars, ""} -> round(dollars * 100) _invalid -> nil end end defp parse_integer(nil), do: nil defp parse_integer(value) when is_binary(value) do case Integer.parse(String.trim(value)) do {integer, ""} -> integer _invalid -> nil end end defp create_error_message(:insufficient_credits), do: "Not enough credits to fund this contract." defp create_error_message(:target_not_found), do: "That target was not found. Check the reference and try again." defp create_error_message(:target_not_public), do: "Contracts can only target publicly visible repositories and findings." defp create_error_message(:unauthorized), do: "Only active accounts can post contracts." defp create_error_message(%Ecto.Changeset{} = changeset) do details = changeset |> Ecto.Changeset.traverse_errors(fn {message, _meta} -> message end) |> Enum.map_join("; ", fn {field, messages} -> "#{field} #{Enum.join(messages, ", ")}" end) "The contract could not be created: #{details}." end defp create_error_message(_reason), do: "The contract could not be created. Try again shortly." @impl true def render(assigns) do ~H"""

Post a contract

<.form for={@form} id="bounty-form" phx-change="validate" phx-submit="create" class="mt-6 space-y-5" > <.input field={@form[:target_type]} type="select" label="Target type" options={@target_types} />
Target {@selected_repository}
<.input field={@form[:repo_query]} type="text" label="Repository" placeholder="Search listed repositories" phx-debounce="200" autocomplete="off" />

No listed repository matches.

<.input :if={@target_type == "infestation"} field={@form[:target_ref]} type="select" label="Infestation" options={@infestation_options} prompt="Choose an infestation" />
<.input field={@form[:target_ref]} type="text" label="Finding" placeholder="Public id from the finding page" autocomplete="off" />

The id in the finding's URL, /findings/<id>.

<.input field={@form[:title]} type="text" label="Title" required maxlength="200" /> <.input field={@form[:description]} type="textarea" label="What should the hunter do?" rows="5" required /> <.input field={@form[:funding]} type="select" label="Fund with" options={@fundings} /> <.input field={@form[:amount]} type="text" label={if @funding == "fiat", do: "Amount (USD)", else: "Amount (credits)"} placeholder={if @funding == "fiat", do: "250", else: "500"} required />

Your balance: {@credit_balance} credits · platform take rate applies at settlement.

Held by Stripe until the work is accepted · platform take rate applies at settlement.

""" end end