diff --git a/config/dev.exs b/config/dev.exs index 978977bfb..02c76cb37 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -142,3 +142,5 @@ config :algora, plausible_url: System.get_env("PLAUSIBLE_URL"), assets_url: System.get_env("ASSETS_URL"), ingest_url: System.get_env("INGEST_URL") + +config :algora, AlgoraWeb.OGImageController, max_age: 0 diff --git a/config/prod.exs b/config/prod.exs index e8386963b..0b8db861c 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -32,5 +32,7 @@ config :algora, assets_url: System.get_env("ASSETS_URL"), ingest_url: System.get_env("INGEST_URL") +config :algora, AlgoraWeb.OGImageController, max_age: 600 + # Runtime production configuration, including reading # of environment variables, is done on config/runtime.exs. diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index d65f5e468..d2a823a57 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -147,6 +147,7 @@ defmodule Algora.Accounts do type: u.type, id: u.id, handle: u.handle, + provider_login: u.provider_login, name: u.name, provider_login: u.provider_login, provider_meta: u.provider_meta, diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index b38021364..bf3acbd73 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -2,6 +2,7 @@ defmodule Algora.Admin do @moduledoc false import Ecto.Query + alias Algora.Accounts alias Algora.Accounts.User alias Algora.Activities.SendDiscord alias Algora.Bounties.Claim @@ -17,6 +18,57 @@ defmodule Algora.Admin do require Logger + def seed do + import Algora.Factory + + title = "Monthly PR Review & Triage Bounty" + + description = """ + ## What needs doing + + Review open pull requests across our repos and: + + 1. Test them locally + 2. Leave helpful comments + 3. Merge or close stale PRs + 4. Tag relevant people when needed + + ## Success looks like + + - [ ] All PRs older than 2 weeks have been reviewed + - [ ] Clear comments left on PRs needing changes + - [ ] Stale PRs (>30 days old) closed with explanation + - [ ] Weekly summary posted in #dev channel + """ + + org = Repo.get_by!(User, handle: "piedpiper0") + user = Repo.get_by!(User, handle: "zcesur") + + repository = insert!(:repository, user: org) + ticket = insert!(:ticket, title: title, repository: repository, description: description) + bounty = insert!(:bounty, ticket: ticket, owner: org, creator: user, amount: Money.new(1000, :USD)) + + for contributor <- Accounts.list_featured_developers() do + insert!(:transaction, + type: :debit, + net_amount: Money.new(1000, :USD), + user_id: org.id, + bounty_id: bounty.id, + status: :succeeded + ) + + insert!(:transaction, + type: :credit, + net_amount: Money.new(1000, :USD), + user_id: contributor.id, + bounty_id: bounty.id, + status: :succeeded + ) + end + + IO.puts("#{AlgoraWeb.Endpoint.url()}/org/#{org.handle}/bounties/#{bounty.id}") + end + def magic(:email, email, return_to), do: AlgoraWeb.Endpoint.url() <> AlgoraWeb.UserAuth.generate_login_path(email, return_to) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index a6969bf4a..d64cc6423 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -27,7 +27,8 @@ defmodule Algora.Bounties do def base_query, do: Bounty @type criterion :: - {:limit, non_neg_integer() | :infinity} + {:id, String.t()} + | {:limit, non_neg_integer() | :infinity} | {:ticket_id, String.t()} | {:owner_id, String.t()} | {:status, :open | :paid} @@ -695,7 +696,7 @@ defmodule Algora.Bounties do bounty_id: String.t(), claims: [Claim.t()] }, - opts :: [ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}] + opts :: [ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, recipient: User.t()] ) :: {:ok, String.t()} | {:error, atom()} def reward_bounty(%{owner: owner, amount: amount, bounty_id: bounty_id, claims: claims}, opts \\ []) do @@ -703,7 +704,8 @@ defmodule Algora.Bounties do %{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"}, ticket_ref: opts[:ticket_ref], bounty_id: bounty_id, - claims: claims + claims: claims, + recipient: opts[:recipient] ) end @@ -997,6 +999,9 @@ defmodule Algora.Bounties do @spec apply_criteria(Ecto.Queryable.t(), [criterion()]) :: Ecto.Queryable.t() defp apply_criteria(query, criteria) do Enum.reduce(criteria, query, fn + {:id, id}, query -> + from([b] in query, where: b.id == ^id) + {:limit, :infinity}, query -> query diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex index 14a60265e..b655e17ba 100644 --- a/lib/algora/bounties/schemas/bounty.ex +++ b/lib/algora/bounties/schemas/bounty.ex @@ -14,6 +14,7 @@ defmodule Algora.Bounties.Bounty do field :autopay_disabled, :boolean, default: false field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], null: false, default: :community field :shared_with, {:array, :string}, null: false, default: [] + field :deadline, :utc_datetime_usec belongs_to :ticket, Algora.Workspace.Ticket belongs_to :owner, User @@ -42,6 +43,13 @@ defmodule Algora.Bounties.Bounty do |> Algora.Validations.validate_money_positive(:amount) end + def settings_changeset(bounty, attrs) do + bounty + |> cast(attrs, [:visibility, :shared_with, :deadline]) + |> Algora.Validations.validate_date_in_future(:deadline) + |> validate_required([:visibility, :shared_with]) + end + def url(%{repository: %{name: name, owner: %{login: login}}, ticket: %{provider: "github", number: number}}) do "https://github.com/#{login}/#{name}/issues/#{number}" end diff --git a/lib/algora/chat/chat.ex b/lib/algora/chat/chat.ex index bd45843a4..47402feb7 100644 --- a/lib/algora/chat/chat.ex +++ b/lib/algora/chat/chat.ex @@ -8,8 +8,13 @@ defmodule Algora.Chat do alias Algora.Chat.Thread alias Algora.Repo - def broadcast(message) do - Phoenix.PubSub.broadcast(Algora.PubSub, "chat:thread:#{message.thread_id}", message) + defmodule MessageCreated do + @moduledoc false + defstruct message: nil, participant: nil + end + + def broadcast(%MessageCreated{} = event) do + Phoenix.PubSub.broadcast(Algora.PubSub, "chat:thread:#{event.message.thread_id}", event) end def subscribe(thread_id) do @@ -23,7 +28,6 @@ defmodule Algora.Chat do |> Thread.changeset(%{title: "#{User.handle(user_1)} <> #{User.handle(user_2)}"}) |> Repo.insert() - # Add participants for user <- [user_1, user_2] do %Participant{} |> Participant.changeset(%{ @@ -46,7 +50,7 @@ defmodule Algora.Chat do |> Repo.insert() participants = Enum.uniq_by([user | admins], & &1.id) - # Add participants + for u <- participants do %Participant{} |> Participant.changeset(%{ @@ -61,20 +65,41 @@ defmodule Algora.Chat do end) end + defp ensure_participant(thread_id, user_id) do + case Repo.fetch_by(Participant, thread_id: thread_id, user_id: user_id) do + {:ok, participant} -> + {:ok, participant} + + {:error, _} -> + %Participant{} + |> Participant.changeset(%{ + thread_id: thread_id, + user_id: user_id, + last_read_at: DateTime.utc_now() + }) + |> Repo.insert() + end + end + + defp insert_message(thread_id, sender_id, content) do + %Message{} + |> Message.changeset(%{ + thread_id: thread_id, + sender_id: sender_id, + content: content + }) + |> Repo.insert() + end + def send_message(thread_id, sender_id, content) do - case %Message{} - |> Message.changeset(%{ - thread_id: thread_id, - sender_id: sender_id, - content: content - }) - |> Repo.insert() do - {:ok, message} -> - message |> Repo.preload(:sender) |> broadcast() - {:ok, message} - - {:error, changeset} -> - {:error, changeset} + with {:ok, participant} <- ensure_participant(thread_id, sender_id), + {:ok, message} <- insert_message(thread_id, sender_id, content) do + broadcast(%MessageCreated{ + message: Repo.preload(message, :sender), + participant: Repo.preload(participant, :user) + }) + + {:ok, message} end end @@ -107,6 +132,12 @@ defmodule Algora.Chat do |> Repo.all() end + def list_participants(thread_id) do + Participant + |> where(thread_id: ^thread_id) + |> Repo.all() + end + def mark_as_read(thread_id, user_id) do Participant |> where(thread_id: ^thread_id, user_id: ^user_id) @@ -145,4 +176,16 @@ defmodule Algora.Chat do thread -> {:ok, thread} end end + + def get_or_create_bounty_thread(bounty) do + case Repo.fetch_by(Thread, bounty_id: bounty.id) do + {:ok, thread} -> + {:ok, thread} + + {:error, _} -> + %Thread{} + |> Thread.changeset(%{title: "Contributor chat", bounty_id: bounty.id}) + |> Repo.insert() + end + end end diff --git a/lib/algora/chat/schemas/thread.ex b/lib/algora/chat/schemas/thread.ex index 98475fcad..0f2da6242 100644 --- a/lib/algora/chat/schemas/thread.ex +++ b/lib/algora/chat/schemas/thread.ex @@ -6,7 +6,7 @@ defmodule Algora.Chat.Thread do typed_schema "threads" do field :title, :string - + field :bounty_id, :string has_many :messages, Algora.Chat.Message has_many :participants, Algora.Chat.Participant has_many :activities, {"thread_activities", Activity}, foreign_key: :assoc_id @@ -16,8 +16,9 @@ defmodule Algora.Chat.Thread do def changeset(thread, attrs) do thread - |> cast(attrs, [:title]) + |> cast(attrs, [:title, :bounty_id]) |> validate_required([:title]) |> generate_id() + |> unique_constraint(:bounty_id) end end diff --git a/lib/algora/shared/validations.ex b/lib/algora/shared/validations.ex index 26231a630..1e57806b0 100644 --- a/lib/algora/shared/validations.ex +++ b/lib/algora/shared/validations.ex @@ -39,4 +39,14 @@ defmodule Algora.Validations do _ -> changeset end end + + def validate_date_in_future(changeset, field) do + validate_change(changeset, field, fn _, date -> + if date && Date.before?(date, DateTime.utc_now()) do + [{field, "must be in the future"}] + else + [] + end + end) + end end diff --git a/lib/algora/workspace/workspace.ex b/lib/algora/workspace/workspace.ex index c6b528e62..1e469feab 100644 --- a/lib/algora/workspace/workspace.ex +++ b/lib/algora/workspace/workspace.ex @@ -200,6 +200,13 @@ defmodule Algora.Workspace do end end + def ensure_user_by_provider_id(token, provider_id) do + case Repo.get_by(User, provider: "github", provider_id: provider_id) do + %User{} = user -> {:ok, user} + nil -> create_user_from_github(token, provider_id) + end + end + def sync_user(user, repository, owner, repo) do github_user = repository["owner"] diff --git a/lib/algora_web/components/bounties.ex b/lib/algora_web/components/bounties.ex index f6985bb9f..8e3176443 100644 --- a/lib/algora_web/components/bounties.ex +++ b/lib/algora_web/components/bounties.ex @@ -25,10 +25,6 @@ defmodule AlgoraWeb.Components.Bounties do
{bounty.repository.owner.name} - <.icon name="tabler-chevron-right" class="mr-1 size-3 text-muted-foreground" /> - - {bounty.repository.name} - #{bounty.ticket.number} {Money.to_string!(bounty.amount)} diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index 4787b78d7..bcc87c201 100644 --- a/lib/algora_web/components/core_components.ex +++ b/lib/algora_web/components/core_components.ex @@ -1288,6 +1288,7 @@ defmodule AlgoraWeb.CoreComponents do defdelegate alert_title(assigns), to: Alert defdelegate alert(assigns), to: Alert defdelegate avatar_fallback(assigns), to: Avatar + defdelegate avatar_group(assigns), to: Avatar defdelegate avatar_image(assigns), to: Avatar defdelegate avatar(assigns), to: Avatar defdelegate badge(assigns), to: AlgoraWeb.Components.UI.Badge diff --git a/lib/algora_web/components/ui/avatar.ex b/lib/algora_web/components/ui/avatar.ex index 66472c7fa..b2acb965f 100644 --- a/lib/algora_web/components/ui/avatar.ex +++ b/lib/algora_web/components/ui/avatar.ex @@ -6,6 +6,8 @@ defmodule AlgoraWeb.Components.UI.Avatar do attr :class, :string, default: nil attr :rest, :global + slot :inner_block, required: true + def avatar(assigns) do ~H"""
@@ -48,4 +50,31 @@ defmodule AlgoraWeb.Components.UI.Avatar do """ end + + attr :class, :string, default: nil + attr :rest, :global + attr :srcs, :list, default: [] + attr :limit, :integer, default: 4 + + def avatar_group(assigns) do + ~H""" +
+ <%= for src <- @srcs |> Enum.take(@limit) do %> + <.avatar class={classes(["ring-4 ring-background", @class])}> + <.avatar_image src={src} /> + <.avatar_fallback> + {Algora.Util.initials(src)} + + + <% end %> + <%= if length(@srcs) > @limit do %> + <.avatar class={classes(["ring-4 ring-background", @class])}> + <.avatar_fallback> + +{length(@srcs) - @limit} + + + <% end %> +
+ """ + end end diff --git a/lib/algora_web/components/ui/card.ex b/lib/algora_web/components/ui/card.ex index 52c61459b..c3b88e75c 100644 --- a/lib/algora_web/components/ui/card.ex +++ b/lib/algora_web/components/ui/card.ex @@ -41,7 +41,7 @@ defmodule AlgoraWeb.Components.UI.Card do def card_header(assigns) do ~H""" -
+
{render_slot(@inner_block)}
""" @@ -77,7 +77,7 @@ defmodule AlgoraWeb.Components.UI.Card do def card_content(assigns) do ~H""" -
+
{render_slot(@inner_block)}
""" @@ -89,7 +89,7 @@ defmodule AlgoraWeb.Components.UI.Card do def card_footer(assigns) do ~H""" -
+
{render_slot(@inner_block)}
""" diff --git a/lib/algora_web/controllers/og_image_controller.ex b/lib/algora_web/controllers/og_image_controller.ex index da99c408e..0aae535da 100644 --- a/lib/algora_web/controllers/og_image_controller.ex +++ b/lib/algora_web/controllers/og_image_controller.ex @@ -7,7 +7,7 @@ defmodule AlgoraWeb.OGImageController do @opts [type: "png", width: 1200, height: 630, scale_factor: 1] - @max_age 600 + defp max_age, do: Algora.config([AlgoraWeb.OGImageController, :max_age]) def generate(conn, %{"path" => path}) do object_path = Path.join(["og"] ++ path ++ ["og.png"]) @@ -49,7 +49,7 @@ defmodule AlgoraWeb.OGImageController do {_, last_modified} -> case DateTime.from_iso8601(convert_to_iso8601(last_modified)) do {:ok, modified_at, _} -> - DateTime.diff(DateTime.utc_now(), modified_at, :second) > @max_age + DateTime.diff(DateTime.utc_now(), modified_at, :second) > max_age() _error -> true @@ -86,7 +86,7 @@ defmodule AlgoraWeb.OGImageController do Task.start(fn -> Algora.S3.upload(body, object_path, content_type: "image/png", - cache_control: "public, max-age=#{@max_age}" + cache_control: "public, max-age=#{max_age()}" ) File.rm(filepath) diff --git a/lib/algora_web/live/bounty_live.ex b/lib/algora_web/live/bounty_live.ex new file mode 100644 index 000000000..92e08af83 --- /dev/null +++ b/lib/algora_web/live/bounty_live.ex @@ -0,0 +1,797 @@ +defmodule AlgoraWeb.BountyLive do + @moduledoc false + use AlgoraWeb, :live_view + + import Ecto.Changeset + import Ecto.Query + + alias Algora.Accounts + alias Algora.Admin + alias Algora.Bounties + alias Algora.Bounties.Bounty + alias Algora.Bounties.LineItem + alias Algora.Chat + alias Algora.Repo + alias Algora.Util + alias Algora.Workspace + + require Logger + + defp tip_options, do: [{"None", 0}, {"10%", 10}, {"20%", 20}, {"50%", 50}] + + defmodule RewardBountyForm do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @primary_key false + embedded_schema do + field :amount, Algora.Types.USD + field :github_handle, :string + field :tip_percentage, :decimal + end + + def changeset(form, attrs) do + form + |> cast(attrs, [:amount, :tip_percentage, :github_handle]) + |> validate_required([:amount, :github_handle]) + |> validate_number(:tip_percentage, greater_than_or_equal_to: 0) + |> Algora.Validations.validate_money_positive(:amount) + end + end + + defmodule ExclusiveBountyForm do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @primary_key false + embedded_schema do + field :github_handle, :string + field :deadline, :date + end + + def changeset(form, attrs) do + form + |> cast(attrs, [:github_handle, :deadline]) + |> validate_required([:github_handle]) + |> Algora.Validations.validate_date_in_future(:deadline) + end + end + + @impl true + def mount(%{"id" => bounty_id}, _session, socket) do + bounty = + Bounty + |> 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 + } + + socket + |> assign(:bounty, bounty) + |> assign(:ticket_ref, ticket_ref) + |> on_mount(bounty) + end + + @impl true + def mount(%{"repo_owner" => repo_owner, "repo_name" => repo_name, "number" => number}, _session, socket) do + number = String.to_integer(number) + + ticket_ref = %{owner: repo_owner, repo: repo_name, number: number} + + bounty = + from(b in Bounty, + join: t in assoc(b, :ticket), + join: r in assoc(t, :repository), + join: u in assoc(r, :user), + where: u.provider == "github", + 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] + ) + |> Repo.one() + |> Repo.preload([:owner, :creator, :transactions, ticket: [repository: [:user]]]) + + socket + |> assign(:bounty, bounty) + |> assign(:ticket_ref, ticket_ref) + |> on_mount(bounty) + end + + defp on_mount(socket, bounty) do + debits = Enum.filter(bounty.transactions, &(&1.type == :debit and &1.status == :succeeded)) + + total_paid = + debits + |> Enum.map(& &1.net_amount) + |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) + + ticket_body_html = Algora.Markdown.render(bounty.ticket.description) + + reward_changeset = + RewardBountyForm.changeset(%RewardBountyForm{}, %{ + tip_percentage: 0, + amount: bounty.amount + }) + + exclusive_changeset = ExclusiveBountyForm.changeset(%ExclusiveBountyForm{}, %{}) + + {:ok, thread} = Chat.get_or_create_bounty_thread(bounty) + messages = thread.id |> Chat.list_messages() |> Repo.preload(:sender) + participants = thread.id |> Chat.list_participants() |> Repo.preload(:user) + + if connected?(socket) do + Chat.subscribe(thread.id) + end + + share_url = + url( + ~p"/#{socket.assigns.ticket_ref.owner}/#{socket.assigns.ticket_ref.repo}/issues/#{socket.assigns.ticket_ref.number}" + ) + + {:ok, + socket + |> assign(:share_url, share_url) + |> assign(:page_title, bounty.ticket.title) + |> assign(:ticket, bounty.ticket) + |> assign(:total_paid, total_paid) + |> assign(:ticket_body_html, ticket_body_html) + |> assign(:show_reward_modal, false) + |> assign(:show_exclusive_modal, false) + |> assign(:selected_context, nil) + |> assign(:recipient, nil) + |> assign(:line_items, []) + |> assign(:thread, thread) + |> assign(:messages, messages) + |> assign(:participants, participants) + |> assign(:reward_form, to_form(reward_changeset)) + |> assign(:exclusive_form, to_form(exclusive_changeset)) + |> assign_exclusives(bounty.shared_with) + |> assign_line_items()} + end + + @impl true + def handle_params(_params, _url, %{assigns: %{current_user: nil}} = socket) do + {:noreply, socket} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket} + end + + @impl true + def handle_info(%Chat.MessageCreated{message: message, participant: participant}, socket) do + socket = + if message.id in Enum.map(socket.assigns.messages, & &1.id), + do: socket, + else: Phoenix.Component.update(socket, :messages, &(&1 ++ [message])) + + socket = + if participant.id in Enum.map(socket.assigns.participants, & &1.id), + do: socket, + else: Phoenix.Component.update(socket, :participants, &(&1 ++ [participant])) + + {:noreply, socket} + end + + @impl true + def handle_event("send_message", %{"message" => content}, socket) do + {:ok, message} = + Chat.send_message( + socket.assigns.thread.id, + socket.assigns.current_user.id, + content + ) + + message = Repo.preload(message, :sender) + + {:noreply, + socket + |> Phoenix.Component.update(:messages, &(&1 ++ [message])) + |> push_event("clear-input", %{selector: "#message-input"})} + end + + @impl true + def handle_event("reward", _params, socket) do + {:noreply, assign(socket, :show_reward_modal, true)} + end + + @impl true + def handle_event("exclusive", _params, socket) do + {:noreply, assign(socket, :show_exclusive_modal, true)} + end + + @impl true + def handle_event("close_drawer", _params, socket) do + {:noreply, close_drawers(socket)} + end + + @impl true + def handle_event("validate_reward", %{"reward_bounty_form" => params}, socket) do + {:noreply, + socket + |> assign(:reward_form, to_form(RewardBountyForm.changeset(%RewardBountyForm{}, params))) + |> assign_line_items()} + end + + @impl true + def handle_event("assign_line_items", %{"reward_bounty_form" => params}, socket) do + {:noreply, + socket + |> assign_recipient(params["github_handle"]) + |> assign_line_items()} + end + + @impl true + def handle_event("pay_with_stripe", %{"reward_bounty_form" => params}, socket) do + changeset = RewardBountyForm.changeset(%RewardBountyForm{}, params) + + case apply_action(changeset, :save) do + {:ok, _data} -> + case reward_bounty(socket, socket.assigns.bounty, changeset) do + {:ok, session_url} -> + {:noreply, redirect(socket, external: session_url)} + + {:error, reason} -> + Logger.error("Failed to create payment session: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Something went wrong")} + end + + {:error, changeset} -> + {:noreply, assign(socket, :reward_form, to_form(changeset))} + end + end + + @impl true + def handle_event("validate_exclusive", %{"exclusive_bounty_form" => params}, socket) do + {:noreply, assign(socket, :exclusive_form, to_form(ExclusiveBountyForm.changeset(%ExclusiveBountyForm{}, params)))} + end + + @impl true + def handle_event("share_exclusive", %{"exclusive_bounty_form" => params}, socket) do + changeset = ExclusiveBountyForm.changeset(%ExclusiveBountyForm{}, params) + bounty = socket.assigns.bounty + + case apply_action(changeset, :save) do + {:ok, data} -> + with {:ok, token} <- Accounts.get_access_token(socket.assigns.current_user), + {:ok, user} <- Workspace.ensure_user(token, data.github_handle), + shared_with = Enum.uniq(bounty.shared_with ++ [user.provider_id]), + {:ok, bounty} <- + bounty + |> Bounty.settings_changeset(%{ + shared_with: shared_with, + deadline: if(data.deadline, do: DateTime.new!(data.deadline, ~T[00:00:00], "Etc/UTC")) + }) + |> Repo.update() do + {:noreply, + socket + |> put_flash(:info, "Bounty shared!") + |> assign(:bounty, bounty) + |> assign_exclusives(shared_with) + |> close_drawers()} + else + nil -> + {:noreply, put_flash(socket, :error, "User not found")} + + {:error, reason} -> + Logger.error("Failed to share bounty: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Something went wrong")} + end + + {:error, changeset} -> + {:noreply, assign(socket, :exclusive_form, to_form(changeset))} + end + end + + @impl true + def render(assigns) do + ~H""" +
+ <.scroll_area class="sm:h-[calc(100svh-96px)] flex-1 pr-6"> +
+ <.card> + <.card_content> +
+
+ <.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_fallback> + {Util.initials(@ticket.repository.user.provider_login)} + + +
+ <.link + href={@ticket.url} + target="_blank" + rel="noopener" + class="block text-xl sm:text-3xl font-semibold text-foreground/90 hover:underline" + > + {@ticket.title} + + <.link + href={@ticket.url} + target="_blank" + 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} + +
+
+
+
+ {Money.to_string!(@bounty.amount)} +
+ <.button phx-click="reward"> + Reward + +
+
+ + +
+ <.card class="flex flex-col items-between justify-center"> + <.card_content> +
+
+ <.card_title> + Share on socials + +
+ <.social_share_button + id="twitter-share-url" + icon="tabler-brand-x" + value={@share_url} + /> + <.social_share_button + id="reddit-share-url" + icon="tabler-brand-reddit" + value={@share_url} + /> + <.social_share_button + id="linkedin-share-url" + icon="tabler-brand-linkedin" + value={@share_url} + /> + <.social_share_button + id="hackernews-share-url" + icon="tabler-brand-ycombinator" + value={@share_url} + /> +
+
+
+ {@bounty.ticket.title} +
+
+ + + <.card class="flex flex-col items-between justify-center"> + <.card_content> +
+
+ <.card_title> + Exclusives + +
+ + <%= if @bounty.deadline do %> + Expires on {Calendar.strftime(@bounty.deadline, "%b %d, %Y")} + <.button + variant="ghost" + size="icon-sm" + phx-click="exclusive" + class="group h-6 w-6" + > + <.icon + name="tabler-pencil" + class="h-4 w-4 text-muted-foreground group-hover:text-foreground" + /> + + <% else %> + + Add a deadline + + <% end %> + +
+ <.button variant="secondary" phx-click="exclusive" class="mt-3"> + <.icon name="tabler-user-plus" class="size-5 mr-2 -ml-1" /> Add + +
+
+ <%= for user <- @exclusives do %> +
+ +
+ <.avatar> + <.avatar_image src={user.avatar_url} /> + <.avatar_fallback>{Util.initials(user.name)} + +
+

+ {user.name} +

+

+ @{user.provider_login} +

+
+
+
+
+ <% end %> +
+
+ + +
+ <.card> + <.card_header> + <.card_title> + Description + + + <.card_content> +
+ {Phoenix.HTML.raw(@ticket_body_html)} +
+ + +
+ + +
+
+
+

+ Contributor chat +

+ + <.avatar_group srcs={Enum.map(@participants, & &1.user.avatar_url)} /> +
+
+ + <.scroll_area + class="flex h-full flex-1 flex-col-reverse gap-6 p-4" + id="messages-container" + phx-hook="ScrollToBottom" + > +
+ <%= for {date, messages} <- @messages + |> Enum.group_by(fn msg -> + case Date.diff(Date.utc_today(), DateTime.to_date(msg.inserted_at)) do + 0 -> "Today" + 1 -> "Yesterday" + n when n <= 7 -> Calendar.strftime(msg.inserted_at, "%A") + _ -> Calendar.strftime(msg.inserted_at, "%b %d") + end + end) + |> Enum.sort_by(fn {_, msgs} -> hd(msgs).inserted_at end, Date) do %> +
+
+ {date} +
+
+ +
+ <%= for message <- Enum.sort_by(messages, & &1.inserted_at, Date) do %> +
+ <.avatar class="h-8 w-8"> + <.avatar_image src={message.sender.avatar_url} /> + <.avatar_fallback> + {Util.initials(message.sender.name)} + + +
+ {message.content} +
+ {message.inserted_at + |> DateTime.to_time() + |> Time.to_string() + |> String.slice(0..4)} +
+
+
+ <% end %> +
+ <% end %> +
+ + +
+
+
+ <.input + id="message-input" + type="text" + name="message" + value="" + placeholder="Type a message..." + autocomplete="off" + class="flex-1 pr-24" + phx-hook="ClearInput" + /> +
+ <.button + type="button" + variant="ghost" + size="icon-sm" + phx-hook="EmojiPicker" + id="emoji-trigger" + > + <.icon name="tabler-mood-smile" class="h-4 w-4" /> + +
+
+ <.button type="submit" size="icon"> + <.icon name="tabler-send" class="h-4 w-4" /> + +
+ + +
+
+
+ + <.drawer + :if={@current_user} + show={@show_exclusive_modal} + on_cancel="close_drawer" + direction="right" + > + <.drawer_header> + <.drawer_title>Share + <.drawer_description> + Make this bounty exclusive to specific users + + + <.drawer_content class="mt-4"> + <.form for={@exclusive_form} phx-change="validate_exclusive" phx-submit="share_exclusive"> +
+
+ <.input label="GitHub handle" field={@exclusive_form[:github_handle]} /> + <.input type="date" label="Deadline" field={@exclusive_form[:deadline]} /> +
+
+ <.button variant="secondary" phx-click="close_drawer" type="button"> + Cancel + + <.button type="submit"> + Submit + +
+
+ + + + <.drawer :if={@current_user} show={@show_reward_modal} on_cancel="close_drawer"> + <.drawer_header> + <.drawer_title>Reward Bounty + <.drawer_description> + You can pay the full bounty now or start with a partial amount - it's up to you! + + + <.drawer_content class="mt-4"> + <.form for={@reward_form} phx-change="validate_reward" phx-submit="pay_with_stripe"> +
+
+ <.card> + <.card_header> + <.card_title>Payment Details + + <.card_content> +
+ <.input + label="Amount" + icon="tabler-currency-dollar" + field={@reward_form[:amount]} + /> + <.input + label="GitHub handle" + field={@reward_form[:github_handle]} + phx-change="assign_line_items" + phx-debounce="500" + /> +
+ <.label>Tip +
+ <.radio_group + class="grid grid-cols-4 gap-4" + field={@reward_form[:tip_percentage]} + options={tip_options()} + /> +
+
+
+ + + <.card> + <.card_header> + <.card_title>Payment Summary + + <.card_content> +
+ <%= for line_item <- @line_items do %> +
+
+ <%= if line_item.image do %> + <.avatar> + <.avatar_image src={line_item.image} /> + <.avatar_fallback> + {Util.initials(line_item.title)} + + + <% else %> +
+ <% end %> +
+
{line_item.title}
+
{line_item.description}
+
+
+
+ {Money.to_string!(line_item.amount)} +
+
+ <% end %> +
+
+
+
+
Total due
+
+
+ {LineItem.gross_amount(@line_items)} +
+
+
+ + +
+
+ <.button variant="secondary" phx-click="close_drawer" type="button"> + Cancel + + <.button type="submit"> + Pay with Stripe <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+
+ + + + """ + end + + defp assign_recipient(socket, github_handle) do + case Workspace.ensure_user(Admin.token!(), github_handle) do + {:ok, recipient} -> + assign(socket, :recipient, recipient) + + _ -> + assign(socket, :recipient, nil) + end + end + + defp assign_line_items(socket) do + amount = calculate_final_amount(socket.assigns.reward_form.source) + recipient = socket.assigns.recipient + ticket_ref = socket.assigns.ticket_ref + + line_items = + if recipient do + [] + else + [ + %LineItem{ + amount: amount, + title: "Recipient", + image: ~p"/images/placeholder-avatar.png", + description: if(ticket_ref, do: "#{ticket_ref[:repo]}##{ticket_ref[:number]}") + } + ] + end ++ + Bounties.generate_line_items( + %{ + owner: socket.assigns.bounty.owner, + amount: amount + }, + ticket_ref: ticket_ref, + recipient: recipient + ) + + assign(socket, :line_items, line_items) + end + + defp reward_bounty(socket, bounty, changeset) do + final_amount = calculate_final_amount(changeset) + + Bounties.reward_bounty( + %{owner: bounty.owner, amount: final_amount, bounty_id: bounty.id, claims: []}, + ticket_ref: socket.assigns.ticket_ref, + recipient: socket.assigns.recipient + ) + end + + defp calculate_final_amount(data_or_changeset) do + tip_percentage = get_field(data_or_changeset, :tip_percentage) || Decimal.new(0) + amount = get_field(data_or_changeset, :amount) || Money.zero(:USD, no_fraction_if_integer: true) + + multiplier = tip_percentage |> Decimal.div(100) |> Decimal.add(1) + Money.mult!(amount, multiplier) + end + + defp social_share_button(assigns) do + ~H""" + <.button + id={@id} + phx-hook="CopyToClipboard" + data-value={@value} + variant="secondary" + phx-click={ + %JS{} + |> JS.hide( + to: "##{@id}-copy-icon", + transition: {"transition-opacity", "opacity-100", "opacity-0"} + ) + |> JS.show( + to: "##{@id}-check-icon", + transition: {"transition-opacity", "opacity-0", "opacity-100"} + ) + } + class="size-6 sm:size-9 relative cursor-pointer text-foreground/90 hover:text-foreground bg-muted" + > + <.icon + id={@id <> "-copy-icon"} + name={@icon} + class="absolute inset-0 m-auto size-6 sm:size-6 flex items-center justify-center" + /> + <.icon + id={@id <> "-check-icon"} + name="tabler-check" + class="absolute inset-0 m-auto hidden size-6 sm:size-6 items-center justify-center" + /> + + """ + end + + defp close_drawers(socket) do + socket + |> assign(:show_reward_modal, false) + |> assign(:show_exclusive_modal, false) + end + + defp assign_exclusives(socket, shared_with) do + exclusives = + Enum.flat_map(shared_with, fn provider_id -> + case Workspace.ensure_user_by_provider_id(Admin.token!(), provider_id) do + {:ok, user} -> [user] + _ -> [] + end + end) + + assign(socket, :exclusives, exclusives) + end +end diff --git a/lib/algora_web/live/chat/thread_live.ex b/lib/algora_web/live/chat/thread_live.ex index ad9175081..6fba11513 100644 --- a/lib/algora_web/live/chat/thread_live.ex +++ b/lib/algora_web/live/chat/thread_live.ex @@ -38,7 +38,7 @@ defmodule AlgoraWeb.Chat.ThreadLive do end @impl true - def handle_info(%Message{} = message, socket) do + def handle_info(%{message: message}, socket) do if message.id in Enum.map(socket.assigns.messages, & &1.id) do {:noreply, socket} else diff --git a/lib/algora_web/live/contract/view_live.ex b/lib/algora_web/live/contract/view_live.ex index 634c80dfa..6878d6ee9 100644 --- a/lib/algora_web/live/contract/view_live.ex +++ b/lib/algora_web/live/contract/view_live.ex @@ -510,7 +510,7 @@ defmodule AlgoraWeb.Contract.ViewLive do end end - def handle_info(%Chat.Message{} = message, socket) do + def handle_info(%Chat.MessageCreated{message: message}, socket) do if message.id in Enum.map(socket.assigns.messages, & &1.id) do {:noreply, socket} else diff --git a/lib/algora_web/live/og/bounty_live.ex b/lib/algora_web/live/og/bounty_live.ex index 28121731e..e854f3bfd 100644 --- a/lib/algora_web/live/og/bounty_live.ex +++ b/lib/algora_web/live/og/bounty_live.ex @@ -2,21 +2,25 @@ defmodule AlgoraWeb.OG.BountyLive do @moduledoc false use AlgoraWeb, :live_view - alias Algora.Bounties + alias Algora.Bounties.Bounty + alias Algora.Repo def mount(%{"id" => id}, _session, socket) do - case Bounties.list_bounties(id: id) do - [bounty | _] -> - socket = - socket - |> assign(:bounty, bounty) - |> assign(:ticket, bounty.ticket) + bounty = + Bounty + |> Repo.get!(id) + |> Repo.preload([:owner, :creator, :transactions, ticket: [repository: [:user]]]) - {:ok, socket} + ticket_ref = %{ + owner: bounty.ticket.repository.user.provider_login, + repo: bounty.ticket.repository.name, + number: bounty.ticket.number + } - [] -> - {:ok, socket |> put_flash(:error, "Bounty not found") |> redirect(to: "/")} - end + {:ok, + socket + |> assign(:bounty, bounty) + |> assign(:ticket_ref, ticket_ref)} end def render(assigns) do @@ -26,19 +30,19 @@ defmodule AlgoraWeb.OG.BountyLive do algora.io
- {@bounty.repository.name}#{@ticket.number} + {@bounty.ticket.repository.name}#{@bounty.ticket.number}
Algora

- {@bounty.owner.name} + {@bounty.ticket.repository.user.provider_login}

@@ -50,19 +54,8 @@ defmodule AlgoraWeb.OG.BountyLive do

- {@ticket.title} + {@bounty.ticket.title}

- <%!--
-
-
- algora.io -
-
-
- Earn bounties for open source contributions -
-
-
--%>
""" end diff --git a/lib/algora_web/live/org/repo_nav.ex b/lib/algora_web/live/org/repo_nav.ex new file mode 100644 index 000000000..f42fcf2e1 --- /dev/null +++ b/lib/algora_web/live/org/repo_nav.ex @@ -0,0 +1,107 @@ +defmodule AlgoraWeb.Org.RepoNav do + @moduledoc false + use Phoenix.Component + + import Phoenix.LiveView + + alias Algora.Organizations + alias AlgoraWeb.OrgAuth + + 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) + + {:cont, + socket + |> assign(:screenshot?, not is_nil(params["screenshot"])) + |> assign(:new_bounty_form, to_form(%{"github_issue_url" => "", "amount" => ""})) + |> 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)} + end + + defp handle_active_tab_params(_params, _url, socket) do + active_tab = + case {socket.view, socket.assigns.live_action} do + {AlgoraWeb.Org.DashboardLive, _} -> :dashboard + {AlgoraWeb.Org.HomeLive, _} -> :home + {AlgoraWeb.Org.BountiesLive, _} -> :bounties + {AlgoraWeb.Org.ProjectsLive, _} -> :projects + {AlgoraWeb.Project.ViewLive, _} -> :projects + {AlgoraWeb.Org.SettingsLive, _} -> :settings + {AlgoraWeb.Org.MembersLive, _} -> :members + {_, _} -> nil + end + + {:cont, assign(socket, :active_tab, active_tab)} + end + + def nav_items(org_handle, current_user_role) do + [ + %{ + title: "Overview", + items: build_nav_items(org_handle, current_user_role) + } + ] + end + + defp build_nav_items(org_handle, current_user_role) do + Enum.filter( + [ + %{ + href: "/org/#{org_handle}", + tab: :dashboard, + icon: "tabler-sparkles", + label: "Dashboard", + roles: [:admin, :mod] + }, + %{ + href: "/org/#{org_handle}/home", + tab: :home, + icon: "tabler-home", + label: "Home", + roles: [:admin, :mod, :expert, :none] + }, + %{ + href: "/org/#{org_handle}/bounties", + tab: :bounties, + icon: "tabler-diamond", + label: "Bounties", + roles: [:admin, :mod, :expert, :none] + }, + %{ + href: "/org/#{org_handle}/leaderboard", + tab: :leaderboard, + icon: "tabler-trophy", + label: "Leaderboard", + roles: [:admin, :mod, :expert, :none] + }, + %{ + href: "/org/#{org_handle}/team", + tab: :team, + icon: "tabler-users", + label: "Team", + roles: [:admin, :mod, :expert, :none] + }, + %{ + href: "/org/#{org_handle}/transactions", + tab: :transactions, + icon: "tabler-credit-card", + label: "Transactions", + roles: [:admin] + }, + %{ + href: "/org/#{org_handle}/settings", + tab: :settings, + icon: "tabler-settings", + label: "Settings", + roles: [:admin] + } + ], + fn item -> current_user_role in item[:roles] end + ) + end +end diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 6e808824d..8bff44acb 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -88,6 +88,15 @@ defmodule AlgoraWeb.Router do end end + scope "/:repo_owner/:repo_name" do + live_session :repo, + layout: {AlgoraWeb.Layouts, :user}, + on_mount: [{AlgoraWeb.UserAuth, :current_user}, AlgoraWeb.Org.RepoNav] do + live "/issues/:number", BountyLive + live "/pull/:number", BountyLive + end + end + scope "/org/:org_handle" do live_session :org, layout: {AlgoraWeb.Layouts, :user}, @@ -95,6 +104,7 @@ defmodule AlgoraWeb.Router do live "/", Org.DashboardLive, :index live "/home", Org.HomeLive, :index live "/bounties", Org.BountiesLive, :index + live "/bounties/:id", BountyLive, :index live "/contracts/:id", Contract.ViewLive live "/team", Org.TeamLive, :index live "/leaderboard", Org.LeaderboardLive, :index diff --git a/priv/repo/migrations/20250401060054_add_deadline_to_bounties.exs b/priv/repo/migrations/20250401060054_add_deadline_to_bounties.exs new file mode 100644 index 000000000..57f052cbc --- /dev/null +++ b/priv/repo/migrations/20250401060054_add_deadline_to_bounties.exs @@ -0,0 +1,9 @@ +defmodule Algora.Repo.Migrations.AddDeadlineToBounties do + use Ecto.Migration + + def change do + alter table(:bounties) do + add :deadline, :utc_datetime_usec + end + end +end diff --git a/priv/repo/migrations/20250401065753_add_bounty_id_to_threads.exs b/priv/repo/migrations/20250401065753_add_bounty_id_to_threads.exs new file mode 100644 index 000000000..290a1ba70 --- /dev/null +++ b/priv/repo/migrations/20250401065753_add_bounty_id_to_threads.exs @@ -0,0 +1,11 @@ +defmodule Algora.Repo.Migrations.AddBountyIdToThreads do + use Ecto.Migration + + def change do + alter table(:threads) do + add :bounty_id, :string, null: true + end + + create unique_index(:threads, [:bounty_id]) + end +end