diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index d64cc6423..fec09a23b 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -94,6 +94,8 @@ defmodule Algora.Bounties do end end + def create_bounty(_params, opts \\ []) + @spec create_bounty( %{ creator: User.t(), @@ -118,7 +120,7 @@ defmodule Algora.Bounties do amount: amount, ticket_ref: %{owner: repo_owner, repo: repo_name, number: number} = ticket_ref }, - opts \\ [] + opts ) do command_id = opts[:command_id] shared_with = opts[:shared_with] || [] @@ -177,6 +179,49 @@ defmodule Algora.Bounties do end) end + @spec create_bounty( + %{ + creator: User.t(), + owner: User.t(), + amount: Money.t(), + title: String.t(), + description: String.t() + }, + opts :: [ + strategy: strategy(), + visibility: Bounty.visibility() | nil, + shared_with: [String.t()] | nil + ] + ) :: + {:ok, Bounty.t()} | {:error, atom()} + def create_bounty(%{creator: creator, owner: owner, amount: amount, title: title, description: description}, opts) do + shared_with = opts[:shared_with] || [] + + Repo.transact(fn -> + with {:ok, ticket} <- + %Ticket{type: :issue} + |> Ticket.changeset(%{title: title, description: description}) + |> Repo.insert(), + {:ok, bounty} <- + do_create_bounty(%{ + creator: creator, + owner: owner, + amount: amount, + ticket: ticket, + visibility: opts[:visibility], + shared_with: shared_with + }), + {:ok, _job} <- notify_bounty(%{owner: owner, bounty: bounty}) do + broadcast() + {:ok, bounty} + else + {:error, _reason} = error -> + Algora.Admin.alert("Error creating bounty: #{inspect(error)}", :error) + error + end + end) + end + defp claim_to_solution(claim) do %{ type: :claim, @@ -337,6 +382,8 @@ defmodule Algora.Bounties do end end + def notify_bounty(bounty, opts \\ []) + @spec notify_bounty( %{ owner: User.t(), @@ -346,7 +393,7 @@ defmodule Algora.Bounties do opts :: [installation_id: integer(), command_id: integer(), command_source: :ticket | :comment] ) :: {:ok, Oban.Job.t()} | {:error, atom()} - def notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, opts \\ []) do + def notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, opts) do %{ owner_login: owner.provider_login, amount: Money.to_string!(bounty.amount, no_fraction_if_integer: true), @@ -362,6 +409,13 @@ defmodule Algora.Bounties do |> Oban.insert() end + @spec notify_bounty(%{owner: User.t(), bounty: Bounty.t()}, opts :: []) :: + {:ok, nil} | {:error, atom()} + def notify_bounty(%{owner: _owner, bounty: bounty}, _opts) do + Algora.Admin.alert("Notify bounty: #{inspect(bounty)}", :error) + {:ok, nil} + end + @spec do_claim_bounty(%{ provider_login: String.t(), token: String.t(), diff --git a/lib/algora/organizations/schemas/member.ex b/lib/algora/organizations/schemas/member.ex index 4aac01418..1891a8479 100644 --- a/lib/algora/organizations/schemas/member.ex +++ b/lib/algora/organizations/schemas/member.ex @@ -34,4 +34,6 @@ defmodule Algora.Organizations.Member do join: o in assoc(m, :org), where: o.id == ^org_id end + + def can_create_bounty?(role), do: role in [:admin, :mod] end diff --git a/lib/algora/workspace/schemas/ticket.ex b/lib/algora/workspace/schemas/ticket.ex index 7f55fcbce..081279f68 100644 --- a/lib/algora/workspace/schemas/ticket.ex +++ b/lib/algora/workspace/schemas/ticket.ex @@ -29,6 +29,13 @@ defmodule Algora.Workspace.Ticket do timestamps() end + def changeset(ticket, params) do + ticket + |> cast(params, [:title, :description, :url]) + |> validate_required([:title]) + |> generate_id() + end + def github_changeset(meta, repo) do params = %{ provider_id: to_string(meta["id"]), diff --git a/lib/algora_web.ex b/lib/algora_web.ex index e857865b3..be9c2c519 100644 --- a/lib/algora_web.ex +++ b/lib/algora_web.ex @@ -56,8 +56,6 @@ defmodule AlgoraWeb do use Phoenix.LiveView, layout: {AlgoraWeb.Layouts, :app} - import Tails, only: [classes: 1] - unquote(html_helpers()) end end @@ -87,13 +85,17 @@ defmodule AlgoraWeb do quote do use Gettext, backend: AlgoraWeb.Gettext + # Core UI components and translation import AlgoraWeb.CoreComponents + + # Enable rendering Svelte components import LiveSvelte + # HTML escaping functionality import Phoenix.HTML - # Core UI components and translation - # Enable rendering Svelte components + # class helpers + import Tails, only: [classes: 1] # Shortcut for generating JS commands alias Phoenix.LiveView.JS diff --git a/lib/algora_web/components/layouts/user.html.heex b/lib/algora_web/components/layouts/user.html.heex index 76b473e66..3431c4f45 100644 --- a/lib/algora_web/components/layouts/user.html.heex +++ b/lib/algora_web/components/layouts/user.html.heex @@ -189,6 +189,24 @@ <% end %> + <%= if main_bounty_form = Map.get(assigns, :main_bounty_form) do %> + <.button phx-click="open_main_bounty_form">Create new bounty + <.drawer + show={@main_bounty_form_open?} + direction="right" + on_cancel="close_main_bounty_form" + > + <.drawer_header> + <.drawer_title>Create new bounty + <.drawer_description> + Create and fund a bounty for an issue + + + <.drawer_content class="mt-4"> + + + + <% end %>
<.link class="group w-fit outline-none" diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 347a9697b..5a300bf22 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -338,7 +338,8 @@ defmodule AlgoraWeb.Webhooks.GithubController do end # TODO: community bounties? - with {:ok, role} when role in [:admin, :mod] <- authorize_user(webhook), + with {:ok, role} <- authorize_user(webhook), + true <- Member.can_create_bounty?(role), {:ok, token} <- Github.get_installation_token(payload["installation"]["id"]), {:ok, installation} <- Workspace.fetch_installation_by( @@ -364,7 +365,7 @@ defmodule AlgoraWeb.Webhooks.GithubController do command_source: command_source ) else - {:ok, _role} -> + false -> {:error, :unauthorized} {:error, _reason} = error -> @@ -382,114 +383,114 @@ defmodule AlgoraWeb.Webhooks.GithubController do number: payload["issue"]["number"] } - case authorize_user(webhook) do - {:ok, role} when role in [:admin, :mod] -> - installation = - Repo.get_by(Installation, - provider: "github", - provider_id: to_string(payload["installation"]["id"]) - ) + with {:ok, role} <- authorize_user(webhook), + true <- Member.can_create_bounty?(role) do + installation = + Repo.get_by(Installation, + provider: "github", + provider_id: to_string(payload["installation"]["id"]) + ) - customer = - Repo.one( - from c in Customer, - left_join: p in assoc(c, :default_payment_method), - where: c.user_id == ^installation.connected_user_id, - select_merge: %{default_payment_method: p} - ) + customer = + Repo.one( + from c in Customer, + left_join: p in assoc(c, :default_payment_method), + where: c.user_id == ^installation.connected_user_id, + select_merge: %{default_payment_method: p} + ) - {:ok, recipient} = get_tip_recipient(webhook, {:tip, args}) + {:ok, recipient} = get_tip_recipient(webhook, {:tip, args}) - {:ok, token} = Github.get_installation_token(payload["installation"]["id"]) + {:ok, token} = Github.get_installation_token(payload["installation"]["id"]) - {:ok, ticket} = - Workspace.ensure_ticket(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number) + {:ok, ticket} = + Workspace.ensure_ticket(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number) - autopay_cooldown_expired? = fn -> - from(t in Tip, - join: recipient in assoc(t, :recipient), - where: recipient.provider_login == ^recipient, - where: t.ticket_id == ^ticket.id, - where: t.status != :cancelled, - order_by: [desc: t.inserted_at], - limit: 1 - ) - |> Repo.one() - |> case do - nil -> - true + autopay_cooldown_expired? = fn -> + from(t in Tip, + join: recipient in assoc(t, :recipient), + where: recipient.provider_login == ^recipient, + where: t.ticket_id == ^ticket.id, + where: t.status != :cancelled, + order_by: [desc: t.inserted_at], + limit: 1 + ) + |> Repo.one() + |> case do + nil -> + true - tip -> - DateTime.diff(DateTime.utc_now(), tip.inserted_at, :millisecond) > :timer.hours(1) - end + tip -> + DateTime.diff(DateTime.utc_now(), tip.inserted_at, :millisecond) > :timer.hours(1) end + end - autopayable? = - not is_nil(installation) and - not is_nil(customer) and - not is_nil(customer.default_payment_method) and - not is_nil(recipient) and - not is_nil(amount) and - autopay_cooldown_expired?.() + autopayable? = + not is_nil(installation) and + not is_nil(customer) and + not is_nil(customer.default_payment_method) and + not is_nil(recipient) and + not is_nil(amount) and + autopay_cooldown_expired?.() + + autopay_result = + if autopayable? do + with {:ok, owner} <- Accounts.fetch_user_by(id: installation.connected_user_id), + {:ok, creator} <- + Workspace.ensure_user(token, payload["repository"]["owner"]["login"]), + {:ok, recipient} <- Workspace.ensure_user(token, recipient), + {:ok, tip} <- + Bounties.do_create_tip( + %{creator: creator, owner: owner, recipient: recipient, amount: amount}, + ticket_ref: ticket_ref, + installation_id: payload["installation"]["id"] + ), + {:ok, invoice} <- + Bounties.create_invoice( + %{ + owner: owner, + amount: amount, + idempotency_key: "tip-#{recipient.provider_login}-#{webhook.delivery}" + }, + ticket_ref: ticket_ref, + tip_id: tip.id, + recipient: recipient + ), + {:ok, _invoice} <- + Invoice.pay(invoice, %{payment_method: customer.default_payment_method.provider_id, off_session: true}) do + Algora.Admin.alert( + "Autopay successful (#{payload["repository"]["full_name"]}##{ticket_ref.number} - #{amount}).", + :info + ) - autopay_result = - if autopayable? do - with {:ok, owner} <- Accounts.fetch_user_by(id: installation.connected_user_id), - {:ok, creator} <- - Workspace.ensure_user(token, payload["repository"]["owner"]["login"]), - {:ok, recipient} <- Workspace.ensure_user(token, recipient), - {:ok, tip} <- - Bounties.do_create_tip( - %{creator: creator, owner: owner, recipient: recipient, amount: amount}, - ticket_ref: ticket_ref, - installation_id: payload["installation"]["id"] - ), - {:ok, invoice} <- - Bounties.create_invoice( - %{ - owner: owner, - amount: amount, - idempotency_key: "tip-#{recipient.provider_login}-#{webhook.delivery}" - }, - ticket_ref: ticket_ref, - tip_id: tip.id, - recipient: recipient - ), - {:ok, _invoice} <- - Invoice.pay(invoice, %{payment_method: customer.default_payment_method.provider_id, off_session: true}) do + {:ok, tip} + else + {:error, reason} -> Algora.Admin.alert( - "Autopay successful (#{payload["repository"]["full_name"]}##{ticket_ref.number} - #{amount}).", - :info + "Autopay failed (#{payload["repository"]["full_name"]}##{ticket_ref.number} - #{amount}): #{inspect(reason)}", + :error ) - {:ok, tip} - else - {:error, reason} -> - Algora.Admin.alert( - "Autopay failed (#{payload["repository"]["full_name"]}##{ticket_ref.number} - #{amount}): #{inspect(reason)}", - :error - ) - - {:error, reason} - end + {:error, reason} end - - case autopay_result do - {:ok, tip} -> - {:ok, tip} - - _ -> - Bounties.create_tip_intent( - %{ - recipient: recipient, - amount: amount, - ticket_ref: ticket_ref - }, - installation_id: payload["installation"]["id"] - ) end - {:ok, _role} -> + case autopay_result do + {:ok, tip} -> + {:ok, tip} + + _ -> + Bounties.create_tip_intent( + %{ + recipient: recipient, + amount: amount, + ticket_ref: ticket_ref + }, + installation_id: payload["installation"]["id"] + ) + end + else + false -> {:error, :unauthorized} {:error, _reason} = error -> diff --git a/lib/algora_web/forms/bounty_form.ex b/lib/algora_web/forms/bounty_form.ex index 7c44c8f88..52c1b14c5 100644 --- a/lib/algora_web/forms/bounty_form.ex +++ b/lib/algora_web/forms/bounty_form.ex @@ -1,6 +1,7 @@ defmodule AlgoraWeb.Forms.BountyForm do @moduledoc false use Ecto.Schema + use AlgoraWeb, :html import Ecto.Changeset @@ -12,6 +13,9 @@ defmodule AlgoraWeb.Forms.BountyForm do field :amount, USD field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], default: :public field :shared_with, {:array, :string}, default: [] + field :type, Ecto.Enum, values: [:github, :custom], default: :github + field :title, :string + field :description, :string embeds_one :ticket_ref, TicketRef, primary_key: false do field :owner, :string @@ -21,11 +25,90 @@ defmodule AlgoraWeb.Forms.BountyForm do end end - def changeset(form, attrs \\ %{}) do + def type_options do + [{"GitHub issue", :github}, {"Custom", :custom}] + end + + def changeset(form, params) do form - |> cast(attrs, [:url, :amount, :visibility, :shared_with]) - |> validate_required([:url, :amount]) + |> cast(params, [:url, :amount, :visibility, :shared_with, :type, :title, :description]) + |> validate_required([:amount, :visibility, :shared_with]) + |> validate_type_fields() |> Validations.validate_money_positive(:amount) |> Validations.validate_ticket_ref(:url, :ticket_ref) end + + defp validate_type_fields(changeset) do + case get_field(changeset, :type) do + :custom -> validate_required(changeset, [:title]) + _ -> validate_required(changeset, [:url]) + end + end + + def bounty_form(assigns) do + ~H""" + <.form id="main-bounty-form" for={@form} phx-submit="create_bounty_main"> +
+
+ <%= for {label, value} <- type_options() do %> + + <% end %> +
+ +
+ <.input + label="URL" + field={@form[:url]} + placeholder="https://github.com/owner/repo/issues/123" + /> +
+ + + + <.input label="Amount" icon="tabler-currency-dollar" field={@form[:amount]} /> +
+
+ <.button variant="secondary" phx-click="close_share_drawer" type="button"> + Cancel + + <.button type="submit"> + Share Bounty <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+ + """ + end end diff --git a/lib/algora_web/live/bounty_live.ex b/lib/algora_web/live/bounty_live.ex index fe647bcfe..9023e86a9 100644 --- a/lib/algora_web/live/bounty_live.ex +++ b/lib/algora_web/live/bounty_live.ex @@ -6,11 +6,13 @@ defmodule AlgoraWeb.BountyLive do import Ecto.Query alias Algora.Accounts + alias Algora.Accounts.User alias Algora.Admin alias Algora.Bounties alias Algora.Bounties.Bounty alias Algora.Bounties.LineItem alias Algora.Chat + alias Algora.Organizations.Member alias Algora.Repo alias Algora.Util alias Algora.Workspace @@ -68,15 +70,22 @@ defmodule AlgoraWeb.BountyLive do |> Repo.get!(bounty_id) |> Repo.preload([:owner, :creator, :transactions, ticket: [repository: [:user]]]) - ticket_ref = %{ - owner: bounty.ticket.repository.user.provider_login, - repo: bounty.ticket.repository.name, - number: bounty.ticket.number - } + {host, ticket_ref} = + if bounty.ticket.repository do + {bounty.ticket.repository.user, + %{ + owner: bounty.ticket.repository.user.provider_login, + repo: bounty.ticket.repository.name, + number: bounty.ticket.number + }} + else + {bounty.owner, nil} + end socket |> assign(:bounty, bounty) |> assign(:ticket_ref, ticket_ref) + |> assign(:host, host) |> on_mount(bounty) end @@ -95,9 +104,8 @@ defmodule AlgoraWeb.BountyLive do where: u.provider_login == ^repo_owner, where: r.name == ^repo_name, where: t.number == ^number, - # TODO: pool bounties - limit: 1, - order_by: [asc: b.inserted_at] + order_by: fragment("CASE WHEN ? = ? THEN 0 ELSE 1 END", u.id, ^socket.assigns.current_org.id), + limit: 1 ) |> Repo.one() |> Repo.preload([:owner, :creator, :transactions, ticket: [repository: [:user]]]) @@ -105,6 +113,7 @@ defmodule AlgoraWeb.BountyLive do socket |> assign(:bounty, bounty) |> assign(:ticket_ref, ticket_ref) + |> assign(:host, bounty.ticket.repository.user) |> on_mount(bounty) end @@ -135,12 +144,17 @@ defmodule AlgoraWeb.BountyLive do end share_url = - url( - ~p"/#{socket.assigns.ticket_ref.owner}/#{socket.assigns.ticket_ref.repo}/issues/#{socket.assigns.ticket_ref.number}" - ) + if socket.assigns.ticket_ref do + url( + ~p"/#{socket.assigns.ticket_ref.owner}/#{socket.assigns.ticket_ref.repo}/issues/#{socket.assigns.ticket_ref.number}" + ) + else + url(~p"/org/#{socket.assigns.bounty.owner.handle}/bounties/#{socket.assigns.bounty.id}") + end {:ok, socket + |> assign(:can_create_bounty, Member.can_create_bounty?(socket.assigns.current_user_role)) |> assign(:share_url, share_url) |> assign(:page_title, bounty.ticket.title) |> assign(:ticket, bounty.ticket) @@ -295,6 +309,11 @@ defmodule AlgoraWeb.BountyLive do end end + @impl true + def handle_event(_event, _params, socket) do + {:noreply, socket} + end + @impl true def render(assigns) do ~H""" @@ -306,9 +325,9 @@ defmodule AlgoraWeb.BountyLive do
<.avatar class="h-12 w-12 sm:h-20 sm:w-20 rounded-lg sm:rounded-2xl"> - <.avatar_image src={@ticket.repository.user.avatar_url} /> + <.avatar_image src={@host.avatar_url} /> <.avatar_fallback> - {Util.initials(@ticket.repository.user.provider_login)} + {Util.initials(User.handle(@host))}
@@ -326,7 +345,7 @@ defmodule AlgoraWeb.BountyLive do rel="noopener" class="block text-base font-display sm:text-xl font-medium text-muted-foreground hover:underline" > - {@ticket.repository.user.provider_login}/{@ticket.repository.name}#{@ticket.number} + {@host.provider_login}/{@ticket.repository.name}#{@ticket.number}
@@ -334,7 +353,7 @@ defmodule AlgoraWeb.BountyLive do
{Money.to_string!(@bounty.amount)}
- <.button phx-click="reward"> + <.button :if={@can_create_bounty} phx-click="reward"> Reward
@@ -377,6 +396,7 @@ defmodule AlgoraWeb.BountyLive do src={~p"/og/0/bounties/#{@bounty.id}"} alt={@bounty.ticket.title} class="object-cover" + loading="lazy" />
@@ -394,6 +414,7 @@ defmodule AlgoraWeb.BountyLive do <%= if @bounty.deadline do %> Expires on {Calendar.strftime(@bounty.deadline, "%b %d, %Y")} <.button + :if={@can_create_bounty} variant="ghost" size="icon-sm" phx-click="exclusive" @@ -406,7 +427,7 @@ defmodule AlgoraWeb.BountyLive do <% else %> @@ -415,9 +436,17 @@ defmodule AlgoraWeb.BountyLive do <% end %> - <.button variant="secondary" phx-click="exclusive" class="mt-3"> + <.button + :if={@can_create_bounty} + variant="secondary" + phx-click="exclusive" + class="mt-3" + > <.icon name="tabler-user-plus" class="size-5 mr-2 -ml-1" /> Add +
+ Open to everyone +
<%= for user <- @exclusives do %> diff --git a/lib/algora_web/live/home_live.ex b/lib/algora_web/live/home_live.ex index 9bc9d6081..191d4cb52 100644 --- a/lib/algora_web/live/home_live.ex +++ b/lib/algora_web/live/home_live.ex @@ -1058,7 +1058,7 @@ defmodule AlgoraWeb.HomeLive do |> redirect(to: AlgoraWeb.UserAuth.generate_login_path(user.email))} {:error, :already_exists} -> - {:noreply, put_flash(socket, :warning, "You have already created a bounty for this ticket")} + {:noreply, put_flash(socket, :warning, "You already have a bounty for this ticket")} {:error, _reason} -> {:noreply, put_flash(socket, :error, "Something went wrong")} diff --git a/lib/algora_web/live/og/bounty_live.ex b/lib/algora_web/live/og/bounty_live.ex index e854f3bfd..95bbc6f5d 100644 --- a/lib/algora_web/live/og/bounty_live.ex +++ b/lib/algora_web/live/og/bounty_live.ex @@ -11,15 +11,22 @@ defmodule AlgoraWeb.OG.BountyLive do |> Repo.get!(id) |> Repo.preload([:owner, :creator, :transactions, ticket: [repository: [:user]]]) - ticket_ref = %{ - owner: bounty.ticket.repository.user.provider_login, - repo: bounty.ticket.repository.name, - number: bounty.ticket.number - } + {host, ticket_ref} = + if bounty.ticket.repository do + {bounty.ticket.repository.user, + %{ + owner: bounty.ticket.repository.user.provider_login, + repo: bounty.ticket.repository.name, + number: bounty.ticket.number + }} + else + {bounty.owner, nil} + end {:ok, socket |> assign(:bounty, bounty) + |> assign(:host, host) |> assign(:ticket_ref, ticket_ref)} end @@ -29,20 +36,19 @@ defmodule AlgoraWeb.OG.BountyLive do
algora.io
-
+
{@bounty.ticket.repository.name}#{@bounty.ticket.number}
- Algora + Algora

- {@bounty.ticket.repository.user.provider_login} + {@host.provider_login}

diff --git a/lib/algora_web/live/org/bounties_live.ex b/lib/algora_web/live/org/bounties_live.ex index 384e329ff..b8b486769 100644 --- a/lib/algora_web/live/org/bounties_live.ex +++ b/lib/algora_web/live/org/bounties_live.ex @@ -287,6 +287,10 @@ defmodule AlgoraWeb.Org.BountiesLive do end} end + def handle_event(_event, _params, socket) do + {:noreply, socket} + end + def handle_params(params, _uri, socket) do current_org = socket.assigns.current_org current_status = get_current_status(params) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 01c45b321..413c1b6ce 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -16,6 +16,7 @@ defmodule AlgoraWeb.Org.DashboardLive do alias Algora.Contracts alias Algora.Github alias Algora.Organizations + alias Algora.Organizations.Member alias Algora.Payments alias Algora.Payments.Transaction alias Algora.Repo @@ -50,7 +51,7 @@ defmodule AlgoraWeb.Org.DashboardLive do def mount(_params, _session, socket) do %{current_org: current_org} = socket.assigns - if socket.assigns.current_user_role in [:admin, :mod] do + if Member.can_create_bounty?(socket.assigns.current_user_role) do if connected?(socket) do Phoenix.PubSub.subscribe(Algora.PubSub, "auth:#{socket.id}") end @@ -633,7 +634,7 @@ defmodule AlgoraWeb.Org.DashboardLive do {:noreply, assign(socket, :bounty_form, to_form(changeset))} {:error, :already_exists} -> - {:noreply, put_flash(socket, :warning, "You have already created a bounty for this ticket")} + {:noreply, put_flash(socket, :warning, "You already have a bounty for this ticket")} {:error, _reason} -> {:noreply, put_flash(socket, :error, "Something went wrong")} @@ -1427,6 +1428,7 @@ defmodule AlgoraWeb.Org.DashboardLive do {@current_org.name}

diff --git a/lib/algora_web/live/org/home_live.ex b/lib/algora_web/live/org/home_live.ex index d8dea6c98..982502904 100644 --- a/lib/algora_web/live/org/home_live.ex +++ b/lib/algora_web/live/org/home_live.ex @@ -231,6 +231,11 @@ defmodule AlgoraWeb.Org.HomeLive do """ end + @impl true + def handle_event(_event, _params, socket) do + {:noreply, socket} + end + defp social_links do [ {:website, "tabler-world"}, diff --git a/lib/algora_web/live/org/leaderboard_live.ex b/lib/algora_web/live/org/leaderboard_live.ex index cdadbae33..01e73c46f 100644 --- a/lib/algora_web/live/org/leaderboard_live.ex +++ b/lib/algora_web/live/org/leaderboard_live.ex @@ -6,6 +6,7 @@ defmodule AlgoraWeb.Org.LeaderboardLive do alias Algora.Accounts.User alias Algora.Organizations + @impl true def mount(%{"org_handle" => handle}, _session, socket) do org = Organizations.get_org_by_handle!(handle) top_earners = Accounts.list_developers(org_id: org.id, earnings_gt: Money.zero(:USD)) @@ -17,6 +18,7 @@ defmodule AlgoraWeb.Org.LeaderboardLive do |> assign(:top_earners, top_earners)} end + @impl true def render(assigns) do ~H"""
@@ -83,4 +85,9 @@ defmodule AlgoraWeb.Org.LeaderboardLive do
""" end + + @impl true + def handle_event(_event, _params, socket) do + {:noreply, socket} + end end diff --git a/lib/algora_web/live/org/nav.ex b/lib/algora_web/live/org/nav.ex index ddd3c56d7..5251da856 100644 --- a/lib/algora_web/live/org/nav.ex +++ b/lib/algora_web/live/org/nav.ex @@ -1,12 +1,19 @@ defmodule AlgoraWeb.Org.Nav do @moduledoc false use Phoenix.Component + use AlgoraWeb, :verified_routes + import Ecto.Changeset import Phoenix.LiveView + alias Algora.Bounties alias Algora.Organizations + alias Algora.Organizations.Member + alias AlgoraWeb.Forms.BountyForm alias AlgoraWeb.OrgAuth + require Logger + def on_mount(:default, %{"org_handle" => org_handle} = params, _session, socket) do current_user = socket.assigns[:current_user] current_org = Organizations.get_org_by(handle: org_handle) @@ -31,15 +38,97 @@ defmodule AlgoraWeb.Org.Nav do # } # end) + main_bounty_form = + if Member.can_create_bounty?(current_user_role) do + to_form(BountyForm.changeset(%BountyForm{}, %{})) + end + {:cont, socket |> assign(:screenshot?, not is_nil(params["screenshot"])) - |> assign(:new_bounty_form, to_form(%{"github_issue_url" => "", "amount" => ""})) + |> assign(:main_bounty_form, main_bounty_form) + |> assign(:main_bounty_form_open?, false) |> assign(:current_org, current_org) |> assign(:current_user_role, current_user_role) |> assign(:nav, nav_items(current_org.handle, current_user_role)) |> assign(:contacts, contacts) - |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3)} + |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3) + |> attach_hook(:handle_event, :handle_event, &handle_event/3)} + end + + defp handle_event("create_bounty_main", %{"bounty_form" => params}, socket) do + changeset = BountyForm.changeset(%BountyForm{}, params) + + case apply_action(changeset, :save) do + {:ok, data} -> + bounty_res = + case data.type do + :github -> + Bounties.create_bounty( + %{ + creator: socket.assigns.current_user, + owner: socket.assigns.current_org, + amount: data.amount, + ticket_ref: %{ + owner: data.ticket_ref.owner, + repo: data.ticket_ref.repo, + number: data.ticket_ref.number + } + }, + visibility: get_field(changeset, :visibility), + shared_with: get_field(changeset, :shared_with) + ) + + :custom -> + Bounties.create_bounty( + %{ + creator: socket.assigns.current_user, + owner: socket.assigns.current_org, + amount: data.amount, + title: data.title, + description: data.description + }, + visibility: get_field(changeset, :visibility), + shared_with: get_field(changeset, :shared_with) + ) + end + + case bounty_res do + {:ok, bounty} -> + to = + case data.type do + :github -> + ~p"/#{data.ticket_ref.owner}/#{data.ticket_ref.repo}/issues/#{data.ticket_ref.number}" + + :custom -> + ~p"/org/#{socket.assigns.current_org.handle}/bounties/#{bounty.id}" + end + + {:cont, redirect(socket, to: to)} + + {:error, :already_exists} -> + {:cont, put_flash(socket, :warning, "You already have a bounty for this ticket")} + + {:error, reason} -> + Logger.error("Failed to create bounty: #{inspect(reason)}") + {:cont, put_flash(socket, :error, "Something went wrong")} + end + + {:error, changeset} -> + {:cont, assign(socket, :main_bounty_form, to_form(changeset))} + end + end + + defp handle_event("open_main_bounty_form", _params, socket) do + {:cont, assign(socket, :main_bounty_form_open?, true)} + end + + defp handle_event("close_main_bounty_form", _params, socket) do + {:cont, assign(socket, :main_bounty_form_open?, false)} + end + + defp handle_event(_event, _params, socket) do + {:cont, socket} end defp handle_active_tab_params(_params, _url, socket) do diff --git a/lib/algora_web/live/org/repo_nav.ex b/lib/algora_web/live/org/repo_nav.ex index f42fcf2e1..92dcaedaf 100644 --- a/lib/algora_web/live/org/repo_nav.ex +++ b/lib/algora_web/live/org/repo_nav.ex @@ -1,26 +1,115 @@ defmodule AlgoraWeb.Org.RepoNav do @moduledoc false use Phoenix.Component + use AlgoraWeb, :verified_routes + import Ecto.Changeset import Phoenix.LiveView + alias Algora.Bounties alias Algora.Organizations + alias Algora.Organizations.Member + alias AlgoraWeb.Forms.BountyForm alias AlgoraWeb.OrgAuth + require Logger + def on_mount(:default, %{"repo_owner" => repo_owner} = params, _session, socket) do current_user = socket.assigns[:current_user] current_org = Organizations.get_org_by(provider_login: repo_owner, provider: "github") current_user_role = OrgAuth.get_user_role(current_user, current_org) + main_bounty_form = + if Member.can_create_bounty?(current_user_role) do + to_form(BountyForm.changeset(%BountyForm{}, %{})) + end + {:cont, socket |> assign(:screenshot?, not is_nil(params["screenshot"])) - |> assign(:new_bounty_form, to_form(%{"github_issue_url" => "", "amount" => ""})) + |> assign(:main_bounty_form, main_bounty_form) + |> assign(:main_bounty_form_open?, false) |> assign(:current_org, current_org) |> assign(:current_user_role, current_user_role) |> assign(:nav, nav_items(current_org.handle, current_user_role)) |> assign(:contacts, []) - |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3)} + |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3) + |> attach_hook(:handle_event, :handle_event, &handle_event/3)} + end + + defp handle_event("create_bounty_main", %{"bounty_form" => params}, socket) do + changeset = BountyForm.changeset(%BountyForm{}, params) + + case apply_action(changeset, :save) do + {:ok, data} -> + bounty_res = + case data.type do + :github -> + Bounties.create_bounty( + %{ + creator: socket.assigns.current_user, + owner: socket.assigns.current_org, + amount: data.amount, + ticket_ref: %{ + owner: data.ticket_ref.owner, + repo: data.ticket_ref.repo, + number: data.ticket_ref.number + } + }, + visibility: get_field(changeset, :visibility), + shared_with: get_field(changeset, :shared_with) + ) + + :custom -> + Bounties.create_bounty( + %{ + creator: socket.assigns.current_user, + owner: socket.assigns.current_org, + amount: data.amount, + title: data.title, + description: data.description + }, + visibility: get_field(changeset, :visibility), + shared_with: get_field(changeset, :shared_with) + ) + end + + case bounty_res do + {:ok, bounty} -> + to = + case data.type do + :github -> + ~p"/#{data.ticket_ref.owner}/#{data.ticket_ref.repo}/issues/#{data.ticket_ref.number}" + + :custom -> + ~p"/org/#{socket.assigns.current_org.handle}/bounties/#{bounty.id}" + end + + {:cont, redirect(socket, to: to)} + + {:error, :already_exists} -> + {:cont, put_flash(socket, :warning, "You already have a bounty for this ticket")} + + {:error, reason} -> + Logger.error("Failed to create bounty: #{inspect(reason)}") + {:cont, put_flash(socket, :error, "Something went wrong")} + end + + {:error, changeset} -> + {:cont, assign(socket, :main_bounty_form, to_form(changeset))} + end + end + + defp handle_event("open_main_bounty_form", _params, socket) do + {:cont, assign(socket, :main_bounty_form_open?, true)} + end + + defp handle_event("close_main_bounty_form", _params, socket) do + {:cont, assign(socket, :main_bounty_form_open?, false)} + end + + defp handle_event(_event, _params, socket) do + {:cont, socket} end defp handle_active_tab_params(_params, _url, socket) do diff --git a/lib/algora_web/live/org/settings_live.ex b/lib/algora_web/live/org/settings_live.ex index d94496902..c2a76306d 100644 --- a/lib/algora_web/live/org/settings_live.ex +++ b/lib/algora_web/live/org/settings_live.ex @@ -325,6 +325,11 @@ defmodule AlgoraWeb.Org.SettingsLive do end end + @impl true + def handle_event(_event, _params, socket) do + {:noreply, socket} + end + @impl true def handle_params(params, _url, socket) do {:noreply, apply_action(socket, socket.assigns.live_action, params)} diff --git a/lib/algora_web/live/org/team_live.ex b/lib/algora_web/live/org/team_live.ex index 99664d14d..e4a10d4f8 100644 --- a/lib/algora_web/live/org/team_live.ex +++ b/lib/algora_web/live/org/team_live.ex @@ -5,6 +5,7 @@ defmodule AlgoraWeb.Org.TeamLive do alias Algora.Accounts.User alias Algora.Organizations + @impl true def mount(%{"org_handle" => handle}, _session, socket) do org = Organizations.get_org_by_handle!(handle) members = Organizations.list_org_members(org) @@ -18,6 +19,7 @@ defmodule AlgoraWeb.Org.TeamLive do |> assign(:contractors, contractors)} end + @impl true def render(assigns) do ~H"""
@@ -115,4 +117,9 @@ defmodule AlgoraWeb.Org.TeamLive do
""" end + + @impl true + def handle_event(_event, _params, socket) do + {:noreply, socket} + end end diff --git a/lib/algora_web/live/org/transactions_live.ex b/lib/algora_web/live/org/transactions_live.ex index d24b4a188..276dc54c8 100644 --- a/lib/algora_web/live/org/transactions_live.ex +++ b/lib/algora_web/live/org/transactions_live.ex @@ -131,6 +131,10 @@ defmodule AlgoraWeb.Org.TransactionsLive do end end + def handle_event(_event, _params, socket) do + {:noreply, socket} + end + defp assign_transactions(socket) do transactions = Payments.list_transactions( diff --git a/lib/algora_web/live/swift_bounties_live.ex b/lib/algora_web/live/swift_bounties_live.ex index 32e71469b..c75133a89 100644 --- a/lib/algora_web/live/swift_bounties_live.ex +++ b/lib/algora_web/live/swift_bounties_live.ex @@ -575,7 +575,7 @@ defmodule AlgoraWeb.SwiftBountiesLive do |> redirect(to: AlgoraWeb.UserAuth.generate_login_path(user.email))} {:error, :already_exists} -> - {:noreply, put_flash(socket, :warning, "You have already created a bounty for this ticket")} + {:noreply, put_flash(socket, :warning, "You already have a bounty for this ticket")} {:error, _reason} -> {:noreply, put_flash(socket, :error, "Something went wrong")} diff --git a/lib/algora_web/live/user/dashboard_live.ex b/lib/algora_web/live/user/dashboard_live.ex index fcd5b9948..3ed0b0cb1 100644 --- a/lib/algora_web/live/user/dashboard_live.ex +++ b/lib/algora_web/live/user/dashboard_live.ex @@ -300,6 +300,7 @@ defmodule AlgoraWeb.User.DashboardLive do src={~p"/og/@/#{@current_user.handle}"} alt={@current_user.name} class="mt-3 w-full aspect-[1200/630] rounded-lg ring-1 ring-input bg-black" + loading="lazy" />
diff --git a/priv/repo/migrations/20250401172828_make_ticket_url_nullable.exs b/priv/repo/migrations/20250401172828_make_ticket_url_nullable.exs new file mode 100644 index 000000000..f9ac8bc39 --- /dev/null +++ b/priv/repo/migrations/20250401172828_make_ticket_url_nullable.exs @@ -0,0 +1,9 @@ +defmodule Algora.Repo.Migrations.MakeTicketUrlNullable do + use Ecto.Migration + + def change do + alter table(:tickets) do + modify :url, :string, null: true + end + end +end