Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
386 lines
12 KiB
Elixir
386 lines
12 KiB
Elixir
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"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
|
<Layouts.page width={:focused}>
|
|
<div class="border-b-2 border-strong pb-6">
|
|
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink">
|
|
Post a contract
|
|
</h1>
|
|
</div>
|
|
|
|
<.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}
|
|
/>
|
|
|
|
<div :if={@target_type == "repository"}>
|
|
<div :if={@selected_repository} class="flex items-center gap-3">
|
|
<span class="font-mono text-xs uppercase tracking-[0.12em] text-ink-faint">Target</span>
|
|
<span
|
|
id="bounty-selected-repository"
|
|
class="min-w-0 flex-1 truncate border-2 border-strong bg-panel px-3 py-2 font-mono text-xs text-ink"
|
|
>
|
|
{@selected_repository}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
phx-click="clear_repository"
|
|
class="cursor-pointer font-mono text-[11px] uppercase tracking-[0.12em] text-signal hover:underline"
|
|
>
|
|
Change
|
|
</button>
|
|
</div>
|
|
|
|
<div :if={is_nil(@selected_repository)}>
|
|
<.input
|
|
field={@form[:repo_query]}
|
|
type="text"
|
|
label="Repository"
|
|
placeholder="Search listed repositories"
|
|
phx-debounce="200"
|
|
autocomplete="off"
|
|
/>
|
|
<ul
|
|
:if={@repo_results != []}
|
|
id="bounty-repo-results"
|
|
class="mt-2 divide-y divide-rule border-2 border-strong bg-ground"
|
|
>
|
|
<li :for={repository <- @repo_results}>
|
|
<button
|
|
type="button"
|
|
phx-click="pick_repository"
|
|
phx-value-ref={repository_ref(repository)}
|
|
class="flex w-full cursor-pointer items-baseline gap-2 px-3 py-2 text-left transition hover:bg-panel"
|
|
>
|
|
<span class="min-w-0 flex-1 truncate font-mono text-xs text-ink">
|
|
{repository.owner}/{repository.name}
|
|
</span>
|
|
<span class="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
|
{repository.host}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
<p
|
|
:if={@repo_results == [] and not blank?(@form.params["repo_query"])}
|
|
class="mt-2 font-mono text-[11px] text-ink-faint"
|
|
>
|
|
No listed repository matches.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<.input
|
|
:if={@target_type == "infestation"}
|
|
field={@form[:target_ref]}
|
|
type="select"
|
|
label="Infestation"
|
|
options={@infestation_options}
|
|
prompt="Choose an infestation"
|
|
/>
|
|
|
|
<div :if={@target_type == "finding"}>
|
|
<.input
|
|
field={@form[:target_ref]}
|
|
type="text"
|
|
label="Finding"
|
|
placeholder="Public id from the finding page"
|
|
autocomplete="off"
|
|
/>
|
|
<p class="mt-2 font-mono text-[11px] leading-5 text-ink-faint">
|
|
The id in the finding's URL, <code>/findings/<id></code>.
|
|
</p>
|
|
</div>
|
|
|
|
<input
|
|
:if={@target_type == "repository"}
|
|
type="hidden"
|
|
name="bounty[target_ref]"
|
|
value={@form.params["target_ref"] || ""}
|
|
/>
|
|
|
|
<.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
|
|
/>
|
|
<p
|
|
:if={@funding == "credits"}
|
|
id="bounty-credit-balance"
|
|
class="-mt-3 font-mono text-[11px] leading-5 text-ink-faint"
|
|
>
|
|
Your balance: <span class="text-ink">{@credit_balance} credits</span>
|
|
· platform take rate applies at settlement.
|
|
</p>
|
|
<p
|
|
:if={@funding == "fiat"}
|
|
class="-mt-3 font-mono text-[11px] leading-5 text-ink-faint"
|
|
>
|
|
Held by Stripe until the work is accepted · platform take rate applies at settlement.
|
|
</p>
|
|
|
|
<button
|
|
id="bounty-create-button"
|
|
type="submit"
|
|
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
|
>
|
|
Post contract
|
|
</button>
|
|
</.form>
|
|
</Layouts.page>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
end
|