From fd51c136cfc7b3ba7b260c80c0cdd69b6919899e Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 13:24:20 +0200 Subject: [PATCH 01/63] add dashboard sections --- lib/algora_web/live/org/dashboard_live.ex | 131 +++++++++++++++++++++- 1 file changed, 125 insertions(+), 6 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index b6f693fe6..0b14d95a9 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -37,11 +37,15 @@ defmodule AlgoraWeb.Org.DashboardLive do Phoenix.PubSub.subscribe(Algora.PubSub, "auth:#{socket.id}") end + bounties = Bounties.list_bounties(owner_id: current_org.id) + {:ok, socket |> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user)) |> assign(:installations, installations) |> assign(:matching_devs, top_earners) + |> assign(:bounties, bounties) + |> assign(:has_more_bounties, false) |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) |> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) |> assign(:tip_form, to_form(TipForm.changeset(%TipForm{}, %{}))) @@ -198,13 +202,33 @@ defmodule AlgoraWeb.Org.DashboardLive do

- Contracts + Contributors +

+

+ Engage your top contributors with tips or contract opportunities +

+
+
+ + + <%= for user <- @matching_devs do %> + <.matching_dev user={user} contracts={@contracts} current_org={@current_org} /> + <% end %> + +
+
+
+ +
+
+

+ Experts

- Engage top-performing developers with contract opportunities + Meet Algora experts versed in your tech stack

-
+
<%= for user <- @matching_devs do %> @@ -214,6 +238,64 @@ defmodule AlgoraWeb.Org.DashboardLive do
+ + <.section + title="Recommended bounties" + subtitle="AI-curated issues from your repositories with suggested bounty amounts" + > +
+
+
    + <%= for bounty <- @bounties do %> + <.link href={bounty.ticket.url} class="block whitespace-nowrap hover:bg-muted/50"> +
  • +
    + <.avatar class="h-8 w-8"> + <.avatar_image src={bounty.repository.owner.avatar_url} /> + <.avatar_fallback> + {Algora.Util.initials(User.handle(bounty.repository.owner))} + + +
    + +
    +
    + {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)} + + {bounty.ticket.title} +
    +
    + +
    + <.button variant="secondary"> + Skip + + <.button> + Confirm + +
    +
  • + + <% end %> +
+
+
+
+ <.icon name="tabler-loader" class="h-6 w-6 animate-spin" /> +
+
+
+
{sidebar(assigns)} @@ -410,9 +492,46 @@ defmodule AlgoraWeb.Org.DashboardLive do View contract <% else %> - <.button phx-click="offer_contract" phx-value-user_id={@user.id}> - Offer contract - +
+ <.button phx-click="offer_contract" phx-value-user_id={@user.id} variant="secondary"> + Tip + + <.button phx-click="offer_contract" phx-value-user_id={@user.id}> + Contract + + <.dropdown_menu> + <.dropdown_menu_trigger> + <.button variant="ghost" size="icon"> + + + + + + Open menu + + + <.dropdown_menu_content> + <.dropdown_menu_item> + View Profile + + <.dropdown_menu_separator /> + <.dropdown_menu_item phx-click="remove"> + Remove + + + +
<% end %> From 032c5ea73766af2ac186f8960a68fce773dfd8f4 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 14:43:47 +0200 Subject: [PATCH 02/63] reorganize --- lib/algora_web/live/org/dashboard_live.ex | 50 ++++++++++------------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 0b14d95a9..70fd488b2 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -192,22 +192,11 @@ defmodule AlgoraWeb.Org.DashboardLive do - <.section> -
- {create_bounty(assigns)} - {create_tip(assigns)} -
- - -
-
-

- Contributors -

-

- Engage your top contributors with tips or contract opportunities -

-
+ <.section + :if={@matching_devs != []} + title={"#{@current_org.name} Contributors"} + subtitle="Engage your top contributors with tips or contract opportunities" + >
@@ -217,17 +206,13 @@ defmodule AlgoraWeb.Org.DashboardLive do
-
+ -
-
-

- Experts -

-

- Meet Algora experts versed in your tech stack -

-
+ <.section + :if={@matching_devs != []} + title="Algora Experts" + subtitle="Meet Algora experts versed in your tech stack" + >
@@ -237,11 +222,11 @@ defmodule AlgoraWeb.Org.DashboardLive do
-
+ <.section - title="Recommended bounties" - subtitle="AI-curated issues from your repositories with suggested bounty amounts" + title={"#{@current_org.name} Bounties"} + subtitle="List of bounties posted by #{@current_org.name}" >
@@ -296,6 +281,13 @@ defmodule AlgoraWeb.Org.DashboardLive do
+ + <.section> +
+ {create_bounty(assigns)} + {create_tip(assigns)} +
+ {sidebar(assigns)} From 177424ebb384d5868b2c7eaa4508a6f812dcf929 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 14:49:25 +0200 Subject: [PATCH 03/63] reorganize --- lib/algora_web/live/org/dashboard_live.ex | 772 +++++++++++----------- 1 file changed, 389 insertions(+), 383 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 70fd488b2..7ee213dca 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -291,344 +291,7 @@ defmodule AlgoraWeb.Org.DashboardLive do {sidebar(assigns)} - <.drawer show={@show_contract_modal} direction="right" on_cancel="close_contract_drawer"> - <.drawer_header :if={@selected_developer}> - <.drawer_title>Offer Contract - <.drawer_description> - Once you send an offer, {@selected_developer.name} will be notified and can accept or decline. - - - <.drawer_content :if={@selected_developer} class="mt-4"> - <.form for={@contract_form} phx-change="validate_contract" phx-submit="create_contract"> -
- <.card> - <.card_header> - <.card_title>Developer - - <.card_content> -
- <.avatar class="h-20 w-20 rounded-full"> - <.avatar_image - src={@selected_developer.avatar_url} - alt={@selected_developer.name} - /> - <.avatar_fallback class="rounded-lg"> - {Algora.Util.initials(@selected_developer.name)} - - - -
-
- {@selected_developer.name} -
- -
- <.link - :if={@selected_developer.provider_login} - href={"https://github.com/#{@selected_developer.provider_login}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - - {@selected_developer.provider_login} - - <.link - :if={@selected_developer.provider_meta["twitter_handle"]} - href={"https://x.com/#{@selected_developer.provider_meta["twitter_handle"]}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - <.icon name="tabler-brand-x" class="h-4 w-4" /> - - {@selected_developer.provider_meta["twitter_handle"]} - - -
- <.icon name="tabler-map-pin" class="h-4 w-4" /> - - {@selected_developer.provider_meta["location"]} - -
-
- <.icon name="tabler-building" class="h-4 w-4" /> - - {@selected_developer.provider_meta["company"] |> String.trim_leading("@")} - -
-
- -
- <%= for tech <- @selected_developer.tech_stack do %> -
- {tech} -
- <% end %> -
-
-
- - - - <.card> - <.card_header> - <.card_title>Contract Details - - <.card_content> -
- <.input - label="Hourly Rate" - icon="tabler-currency-dollar" - field={@contract_form[:hourly_rate]} - /> - <.input label="Hours per Week" field={@contract_form[:hours_per_week]} /> -
- - - -
- <.button variant="secondary" phx-click="close_contract_drawer" type="button"> - Cancel - - <.button type="submit"> - Send Contract Offer <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> - -
-
- - - - """ - end - - defp matching_dev(assigns) do - ~H""" - - -
-
- <.link navigate={User.url(@user)}> - <.avatar class="h-20 w-20 rounded-full"> - <.avatar_image src={@user.avatar_url} alt={@user.name} /> - <.avatar_fallback class="rounded-lg"> - {Algora.Util.initials(@user.name)} - - - - -
-
- <.link navigate={User.url(@user)} class="font-semibold hover:underline"> - {@user.name} - -
- -
- <.link - :if={@user.provider_login} - href={"https://github.com/#{@user.provider_login}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - - {@user.provider_login} - - <.link - :if={@user.provider_meta["twitter_handle"]} - href={"https://x.com/#{@user.provider_meta["twitter_handle"]}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - <.icon name="tabler-brand-x" class="h-4 w-4" /> - {@user.provider_meta["twitter_handle"]} - -
- <.icon name="tabler-map-pin" class="h-4 w-4" /> - {@user.provider_meta["location"]} -
-
- <.icon name="tabler-building" class="h-4 w-4" /> - - {@user.provider_meta["company"] |> String.trim_leading("@")} - -
-
- -
- <%= for tech <- @user.tech_stack do %> -
- {tech} -
- <% end %> -
-
-
- <%= if contract_for_user(@contracts, @user) do %> - <.button - variant="secondary" - navigate={ - ~p"/org/#{@current_org.handle}/contracts/#{contract_for_user(@contracts, @user).id}" - } - > - View contract - - <% else %> -
- <.button phx-click="offer_contract" phx-value-user_id={@user.id} variant="secondary"> - Tip - - <.button phx-click="offer_contract" phx-value-user_id={@user.id}> - Contract - - <.dropdown_menu> - <.dropdown_menu_trigger> - <.button variant="ghost" size="icon"> - - - - - - Open menu - - - <.dropdown_menu_content> - <.dropdown_menu_item> - View Profile - - <.dropdown_menu_separator /> - <.dropdown_menu_item phx-click="remove"> - Remove - - - -
- <% end %> -
- - - """ - end - - defp contract_for_user(contracts, user) do - Enum.find(contracts, fn contract -> contract.contractor_id == user.id end) - end - - defp create_bounty(assigns) do - ~H""" - <.card> - <.card_header> -
- <.icon name="tabler-diamond" class="h-8 w-8" /> -

Post a bounty

-
- - <.card_content> - <.simple_form for={@bounty_form} phx-submit="create_bounty"> -
- <.input - label="URL" - field={@bounty_form[:url]} - placeholder="https://github.com/swift-lang/swift/issues/1337" - /> - <.input label="Amount" icon="tabler-currency-dollar" field={@bounty_form[:amount]} /> -

- Tip: - You can also comment /bounty $100 - to create a bounty on GitHub - -

-
- <.button>Submit -
-
- - - - """ - end - - defp create_tip(assigns) do - ~H""" - <.card> - <.card_header> -
- <.icon name="tabler-gift" class="h-8 w-8" /> -

Tip a developer

-
- - <.card_content> - <.simple_form for={@tip_form} phx-submit="create_tip"> -
- <.input label="GitHub handle" field={@tip_form[:github_handle]} placeholder="jsmith" /> - <.input label="Amount" icon="tabler-currency-dollar" field={@tip_form[:amount]} /> -

- Tip: - You can also comment /tip $100 @username - to create a tip on GitHub - -

-
- <.button>Submit -
-
- - - - """ - end - - defp sidebar(assigns) do - ~H""" - + {contract_modal(assigns)} """ end @@ -821,60 +484,403 @@ defmodule AlgoraWeb.Org.DashboardLive do assign(socket, :payable_bounties, payable_bounties) end - defp assign_contracts(socket) do - contracts = Contracts.list_contracts(client_id: socket.assigns.current_org.id, status: :draft) - - assign(socket, :contracts, contracts) + defp assign_contracts(socket) do + contracts = Contracts.list_contracts(client_id: socket.assigns.current_org.id, status: :draft) + + assign(socket, :contracts, contracts) + end + + defp assign_achievements(socket) do + status_fns = [ + {&personalize_status/1, "Personalize Algora", nil}, + {&install_app_status/1, "Install the Algora app", nil}, + {&create_bounty_status/1, "Create a bounty", nil}, + {&reward_bounty_status/1, "Reward a bounty", nil}, + {&share_with_friend_status/1, "Share Algora with a friend", nil} + ] + + {achievements, _} = + Enum.reduce_while(status_fns, {[], false}, fn {status_fn, name, path}, {acc, found_current} -> + status = status_fn.(socket) + + result = + cond do + found_current -> {acc ++ [%{status: status, name: name, path: path}], found_current} + status == :completed -> {acc ++ [%{status: status, name: name, path: path}], false} + true -> {acc ++ [%{status: :current, name: name, path: path}], true} + end + + {:cont, result} + end) + + assign(socket, :achievements, achievements) + end + + defp personalize_status(_socket), do: :completed + + defp install_app_status(socket) do + case socket.assigns.installations do + [] -> :upcoming + _ -> :completed + end + end + + defp create_bounty_status(socket) do + case Bounties.list_bounties(owner_id: socket.assigns.current_org.id, limit: 1) do + [] -> :upcoming + _ -> :completed + end + end + + defp reward_bounty_status(socket) do + case Bounties.list_bounties(owner_id: socket.assigns.current_org.id, status: :paid, limit: 1) do + [] -> :upcoming + _ -> :completed + end + end + + defp share_with_friend_status(_socket), do: :upcoming + + defp matching_dev(assigns) do + ~H""" + + +
+
+ <.link navigate={User.url(@user)}> + <.avatar class="h-20 w-20 rounded-full"> + <.avatar_image src={@user.avatar_url} alt={@user.name} /> + <.avatar_fallback class="rounded-lg"> + {Algora.Util.initials(@user.name)} + + + + +
+
+ <.link navigate={User.url(@user)} class="font-semibold hover:underline"> + {@user.name} + +
+ +
+ <.link + :if={@user.provider_login} + href={"https://github.com/#{@user.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + + {@user.provider_login} + + <.link + :if={@user.provider_meta["twitter_handle"]} + href={"https://x.com/#{@user.provider_meta["twitter_handle"]}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="tabler-brand-x" class="h-4 w-4" /> + {@user.provider_meta["twitter_handle"]} + +
+ <.icon name="tabler-map-pin" class="h-4 w-4" /> + {@user.provider_meta["location"]} +
+
+ <.icon name="tabler-building" class="h-4 w-4" /> + + {@user.provider_meta["company"] |> String.trim_leading("@")} + +
+
+ +
+ <%= for tech <- @user.tech_stack do %> +
+ {tech} +
+ <% end %> +
+
+
+ <%= if contract_for_user(@contracts, @user) do %> + <.button + variant="secondary" + navigate={ + ~p"/org/#{@current_org.handle}/contracts/#{contract_for_user(@contracts, @user).id}" + } + > + View contract + + <% else %> +
+ <.button phx-click="offer_contract" phx-value-user_id={@user.id} variant="secondary"> + Tip + + <.button phx-click="offer_contract" phx-value-user_id={@user.id}> + Contract + + <.dropdown_menu> + <.dropdown_menu_trigger> + <.button variant="ghost" size="icon"> + + + + + + Open menu + + + <.dropdown_menu_content> + <.dropdown_menu_item> + View Profile + + <.dropdown_menu_separator /> + <.dropdown_menu_item phx-click="remove"> + Remove + + + +
+ <% end %> +
+ + + """ + end + + defp contract_for_user(contracts, user) do + Enum.find(contracts, fn contract -> contract.contractor_id == user.id end) end - defp assign_achievements(socket) do - status_fns = [ - {&personalize_status/1, "Personalize Algora", nil}, - {&install_app_status/1, "Install the Algora app", nil}, - {&create_bounty_status/1, "Create a bounty", nil}, - {&reward_bounty_status/1, "Reward a bounty", nil}, - {&share_with_friend_status/1, "Share Algora with a friend", nil} - ] + defp create_bounty(assigns) do + ~H""" + <.card> + <.card_header> +
+ <.icon name="tabler-diamond" class="h-8 w-8" /> +

Post a bounty

+
+ + <.card_content> + <.simple_form for={@bounty_form} phx-submit="create_bounty"> +
+ <.input + label="URL" + field={@bounty_form[:url]} + placeholder="https://github.com/swift-lang/swift/issues/1337" + /> + <.input label="Amount" icon="tabler-currency-dollar" field={@bounty_form[:amount]} /> +

+ Tip: + You can also comment /bounty $100 + to create a bounty on GitHub + +

+
+ <.button>Submit +
+
+ + + + """ + end - {achievements, _} = - Enum.reduce_while(status_fns, {[], false}, fn {status_fn, name, path}, {acc, found_current} -> - status = status_fn.(socket) + defp create_tip(assigns) do + ~H""" + <.card> + <.card_header> +
+ <.icon name="tabler-gift" class="h-8 w-8" /> +

Tip a developer

+
+ + <.card_content> + <.simple_form for={@tip_form} phx-submit="create_tip"> +
+ <.input label="GitHub handle" field={@tip_form[:github_handle]} placeholder="jsmith" /> + <.input label="Amount" icon="tabler-currency-dollar" field={@tip_form[:amount]} /> +

+ Tip: + You can also comment /tip $100 @username + to create a tip on GitHub + +

+
+ <.button>Submit +
+
+ + + + """ + end - result = - cond do - found_current -> {acc ++ [%{status: status, name: name, path: path}], found_current} - status == :completed -> {acc ++ [%{status: status, name: name, path: path}], false} - true -> {acc ++ [%{status: :current, name: name, path: path}], true} - end + defp sidebar(assigns) do + ~H""" + + """ + end - {:cont, result} - end) + defp contract_modal(assigns) do + ~H""" + <.drawer show={@show_contract_modal} direction="right" on_cancel="close_contract_drawer"> + <.drawer_header :if={@selected_developer}> + <.drawer_title>Offer Contract + <.drawer_description> + Once you send an offer, {@selected_developer.name} will be notified and can accept or decline. + + + <.drawer_content :if={@selected_developer} class="mt-4"> + <.form for={@contract_form} phx-change="validate_contract" phx-submit="create_contract"> +
+ <.card> + <.card_header> + <.card_title>Developer + + <.card_content> +
+ <.avatar class="h-20 w-20 rounded-full"> + <.avatar_image + src={@selected_developer.avatar_url} + alt={@selected_developer.name} + /> + <.avatar_fallback class="rounded-lg"> + {Algora.Util.initials(@selected_developer.name)} + + - assign(socket, :achievements, achievements) - end +
+
+ {@selected_developer.name} +
- defp personalize_status(_socket), do: :completed +
+ <.link + :if={@selected_developer.provider_login} + href={"https://github.com/#{@selected_developer.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + + {@selected_developer.provider_login} + + <.link + :if={@selected_developer.provider_meta["twitter_handle"]} + href={"https://x.com/#{@selected_developer.provider_meta["twitter_handle"]}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="tabler-brand-x" class="h-4 w-4" /> + + {@selected_developer.provider_meta["twitter_handle"]} + + +
+ <.icon name="tabler-map-pin" class="h-4 w-4" /> + + {@selected_developer.provider_meta["location"]} + +
+
+ <.icon name="tabler-building" class="h-4 w-4" /> + + {@selected_developer.provider_meta["company"] |> String.trim_leading("@")} + +
+
- defp install_app_status(socket) do - case socket.assigns.installations do - [] -> :upcoming - _ -> :completed - end - end +
+ <%= for tech <- @selected_developer.tech_stack do %> +
+ {tech} +
+ <% end %> +
+
+
+ + - defp create_bounty_status(socket) do - case Bounties.list_bounties(owner_id: socket.assigns.current_org.id, limit: 1) do - [] -> :upcoming - _ -> :completed - end - end + <.card> + <.card_header> + <.card_title>Contract Details + + <.card_content> +
+ <.input + label="Hourly Rate" + icon="tabler-currency-dollar" + field={@contract_form[:hourly_rate]} + /> + <.input label="Hours per Week" field={@contract_form[:hours_per_week]} /> +
+ + - defp reward_bounty_status(socket) do - case Bounties.list_bounties(owner_id: socket.assigns.current_org.id, status: :paid, limit: 1) do - [] -> :upcoming - _ -> :completed - end +
+ <.button variant="secondary" phx-click="close_contract_drawer" type="button"> + Cancel + + <.button type="submit"> + Send Contract Offer <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+
+ + + + """ end - - defp share_with_friend_status(_socket), do: :upcoming end From b454cc77c2d5eadf2b6924fa2e29f2e55922e48e Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 15:08:10 +0200 Subject: [PATCH 04/63] add preview page --- lib/algora_web/live/org/preview_nav.ex | 62 ++++++++++++++++++++++++++ lib/algora_web/router.ex | 8 ++++ 2 files changed, 70 insertions(+) create mode 100644 lib/algora_web/live/org/preview_nav.ex diff --git a/lib/algora_web/live/org/preview_nav.ex b/lib/algora_web/live/org/preview_nav.ex new file mode 100644 index 000000000..f5b2f83ea --- /dev/null +++ b/lib/algora_web/live/org/preview_nav.ex @@ -0,0 +1,62 @@ +defmodule AlgoraWeb.Org.PreviewNav do + @moduledoc false + use Phoenix.Component + + import Phoenix.LiveView + + alias Algora.Accounts.User + alias Algora.Github.TokenPool + alias Algora.Workspace + + def on_mount(:default, %{"repo_owner" => repo_owner, "repo_name" => repo_name}, _session, socket) do + token = TokenPool.get_token() + {:ok, _repo} = Workspace.ensure_repository(token, repo_owner, repo_name) + {:ok, user} = Workspace.ensure_user(token, repo_owner) + + current_org = %User{ + id: Ecto.UUID.generate(), + provider: "github", + provider_login: repo_owner, + name: user.name, + handle: user.handle, + avatar_url: user.avatar_url + } + + {:cont, + socket + |> assign(:current_context, current_org) + |> assign(:all_contexts, [current_org]) + |> assign(:new_bounty_form, to_form(%{"github_issue_url" => "", "amount" => ""})) + |> assign(:current_org, current_org) + |> assign(:current_user_role, :admin) + |> assign(:nav, nav_items(repo_owner, repo_name)) + |> 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 + {_, _} -> nil + end + + {:cont, assign(socket, :active_tab, active_tab)} + end + + def nav_items(repo_owner, repo_name) do + [ + %{ + title: "Overview", + items: [ + %{ + href: "/go/#{repo_owner}/#{repo_name}", + tab: :dashboard, + icon: "tabler-sparkles", + label: "Dashboard" + } + ] + } + ] + end +end diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 35a8243b2..027b824c4 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -71,6 +71,14 @@ defmodule AlgoraWeb.Router do get "/:provider/installation", InstallationCallbackController, :new end + scope "/go/:repo_owner/:repo_name" do + live_session :preview, + layout: {AlgoraWeb.Layouts, :user}, + on_mount: [{AlgoraWeb.UserAuth, :current_user}, AlgoraWeb.Org.PreviewNav] do + live "/", Org.DashboardLive, :preview + end + end + scope "/org/:org_handle" do live_session :org, layout: {AlgoraWeb.Layouts, :user}, From 73b5abc7bdb327d19ed9fb086cb10667c78e4dc6 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 16:39:10 +0200 Subject: [PATCH 05/63] feat: implement organization preview functionality - Added `init_preview/2` function to create a new organization and user for preview contexts. - Introduced `OrgPreviewCallbackController` to handle preview login and session management. - Updated routing to include a new preview path and adjusted user authentication flow for preview contexts. - Enhanced `UserAuth` with methods for signing and verifying preview codes. - Updated `PreviewNav` to manage navigation for preview contexts effectively. - Added tests for the new preview functionality and verification logic. --- lib/algora/accounts/accounts.ex | 16 +++++- lib/algora/organizations/organizations.ex | 29 +++++++++++ .../org_preview_callback_controller.ex | 39 ++++++++++++++ lib/algora_web/controllers/user_auth.ex | 30 +++++++++++ lib/algora_web/live/org/preview_nav.ex | 51 +++++++++---------- lib/algora_web/router.ex | 1 + test/algora/organizations_test.exs | 14 +++++ .../algora_web/controllers/user_auth_test.exs | 43 ++++++++++++++++ 8 files changed, 195 insertions(+), 28 deletions(-) create mode 100644 lib/algora_web/controllers/org_preview_callback_controller.ex diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index b23ba1be4..e059c1439 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -413,8 +413,20 @@ defmodule Algora.Accounts do def get_last_context_user(%User{} = user) do case last_context(user) do - "personal" -> user - last_context -> get_user_by_handle(last_context) + "personal" -> + user + + "preview/" <> ctx -> + case String.split(ctx, "/") do + [id, _repo_owner, _repo_name] -> get_user(id) + _ -> nil + end + + "repo/" <> _repo_full_name -> + user + + last_context -> + get_user_by_handle(last_context) end end diff --git a/lib/algora/organizations/organizations.ex b/lib/algora/organizations/organizations.ex index 7a101fc9f..820bc508a 100644 --- a/lib/algora/organizations/organizations.ex +++ b/lib/algora/organizations/organizations.ex @@ -3,9 +3,11 @@ defmodule Algora.Organizations do import Ecto.Query alias Algora.Accounts.User + alias Algora.Github.TokenPool alias Algora.Organizations.Member alias Algora.Organizations.Org alias Algora.Repo + alias Algora.Workspace def create_organization(params) do %User{type: :organization} @@ -228,4 +230,31 @@ defmodule Algora.Organizations do where: c.client_id == ^org.id and c.contractor_id == u.id ) end + + def init_preview(repo_owner, repo_name) do + token = TokenPool.get_token() + + {:ok, _repo} = Workspace.ensure_repository(token, repo_owner, repo_name) + {:ok, owner} = Workspace.ensure_user(token, repo_owner) + + Repo.transact(fn repo -> + {:ok, org} = + repo.insert(%User{ + type: :organization, + id: Nanoid.generate(), + display_name: owner.name, + avatar_url: owner.avatar_url, + last_context: "repo/#{repo_owner}/#{repo_name}" + }) + + {:ok, user} = + repo.insert(%User{ + type: :individual, + id: Nanoid.generate(), + last_context: "preview/#{org.id}/#{repo_owner}/#{repo_name}" + }) + + {:ok, %{org: org, user: user}} + end) + end end diff --git a/lib/algora_web/controllers/org_preview_callback_controller.ex b/lib/algora_web/controllers/org_preview_callback_controller.ex new file mode 100644 index 000000000..71395f347 --- /dev/null +++ b/lib/algora_web/controllers/org_preview_callback_controller.ex @@ -0,0 +1,39 @@ +defmodule AlgoraWeb.OrgPreviewCallbackController do + use AlgoraWeb, :controller + + import Ecto.Query + + alias Algora.Accounts.User + alias Algora.Repo + + require Logger + + def new(conn, %{"id" => id, "token" => token} = params) do + with {:ok, _login_token} <- AlgoraWeb.UserAuth.verify_preview_code(token, id), + {:ok, user} <- + Repo.fetch_one( + from u in User, + where: u.id == ^id, + where: is_nil(u.handle), + where: is_nil(u.provider_login) + ) do + conn = + if params["return_to"] do + put_session(conn, :user_return_to, String.trim_leading(params["return_to"], AlgoraWeb.Endpoint.url())) + else + conn + end + + conn + |> put_flash(:info, "Welcome to Algora!") + |> AlgoraWeb.UserAuth.log_in_user(user) + else + {:error, reason} -> + Logger.debug("failed preview exchange #{inspect(reason)}") + + conn + |> put_flash(:error, "Something went wrong. Please try again.") + |> redirect(to: "/") + end + end +end diff --git a/lib/algora_web/controllers/user_auth.ex b/lib/algora_web/controllers/user_auth.ex index 4f50d4e34..85ac96978 100644 --- a/lib/algora_web/controllers/user_auth.ex +++ b/lib/algora_web/controllers/user_auth.ex @@ -224,6 +224,14 @@ defmodule AlgoraWeb.UserAuth do defp maybe_store_return_to(conn), do: conn def signed_in_path_from_context("personal"), do: ~p"/home" + + def signed_in_path_from_context("preview/" <> ctx) do + case String.split(ctx, "/") do + [_id, repo_owner, repo_name] -> ~p"/go/#{repo_owner}/#{repo_name}" + _ -> ~p"/home" + end + end + def signed_in_path_from_context(org_handle), do: ~p"/org/#{org_handle}" def signed_in_path(%User{} = user) do @@ -293,6 +301,28 @@ defmodule AlgoraWeb.UserAuth do end end + def sign_preview_code(payload) do + Phoenix.Token.sign(AlgoraWeb.Endpoint, login_code_salt(), payload, max_age: login_code_ttl()) + end + + def verify_preview_code(code, id) do + case Phoenix.Token.verify(AlgoraWeb.Endpoint, login_code_salt(), code, max_age: login_code_ttl()) do + {:ok, token_id} -> + if token_id == id do + {:ok, token_id} + else + {:error, :invalid_id} + end + + {:error, reason} -> + {:error, reason} + end + end + + def preview_path(id, token), do: ~p"/preview?id=#{id}&token=#{token}" + + def preview_path(id, token, return_to), do: ~p"/preview?id=#{id}&token=#{token}&return_to=#{return_to}" + def login_path(email, token), do: ~p"/callbacks/email/oauth?email=#{email}&token=#{token}" def login_path(email, token, return_to), diff --git a/lib/algora_web/live/org/preview_nav.ex b/lib/algora_web/live/org/preview_nav.ex index f5b2f83ea..9be3f7fc1 100644 --- a/lib/algora_web/live/org/preview_nav.ex +++ b/lib/algora_web/live/org/preview_nav.ex @@ -1,37 +1,36 @@ defmodule AlgoraWeb.Org.PreviewNav do @moduledoc false use Phoenix.Component + use AlgoraWeb, :verified_routes import Phoenix.LiveView - alias Algora.Accounts.User - alias Algora.Github.TokenPool - alias Algora.Workspace + alias Algora.Organizations def on_mount(:default, %{"repo_owner" => repo_owner, "repo_name" => repo_name}, _session, socket) do - token = TokenPool.get_token() - {:ok, _repo} = Workspace.ensure_repository(token, repo_owner, repo_name) - {:ok, user} = Workspace.ensure_user(token, repo_owner) - - current_org = %User{ - id: Ecto.UUID.generate(), - provider: "github", - provider_login: repo_owner, - name: user.name, - handle: user.handle, - avatar_url: user.avatar_url - } - - {:cont, - socket - |> assign(:current_context, current_org) - |> assign(:all_contexts, [current_org]) - |> assign(:new_bounty_form, to_form(%{"github_issue_url" => "", "amount" => ""})) - |> assign(:current_org, current_org) - |> assign(:current_user_role, :admin) - |> assign(:nav, nav_items(repo_owner, repo_name)) - |> assign(:contacts, []) - |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3)} + current_context = socket.assigns[:current_context] + + if current_context && current_context.last_context == "repo/#{repo_owner}/#{repo_name}" do + {:cont, + socket + |> assign(:new_bounty_form, to_form(%{"github_issue_url" => "", "amount" => ""})) + |> assign(:current_org, current_context) + |> assign(:current_user_role, :admin) + |> assign(:nav, nav_items(repo_owner, repo_name)) + |> assign(:contacts, []) + |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3)} + else + case Organizations.init_preview(repo_owner, repo_name) do + {:ok, %{user: user, org: _org}} -> + token = AlgoraWeb.UserAuth.sign_preview_code(user.id) + path = AlgoraWeb.UserAuth.preview_path(user.id, token, ~p"/go/#{repo_owner}/#{repo_name}") + + {:halt, redirect(socket, to: path)} + + {:error, reason} -> + {:cont, put_flash(socket, :error, "Failed to initialize preview: #{inspect(reason)}")} + end + end end defp handle_active_tab_params(_params, _url, socket) do diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 027b824c4..860a99f26 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -63,6 +63,7 @@ defmodule AlgoraWeb.Router do get "/a/:table_prefix/:activity_id", ActivityController, :get get "/auth/logout", OAuthCallbackController, :sign_out get "/tip", TipController, :create + get "/preview", OrgPreviewCallbackController, :new scope "/callbacks" do get "/stripe/refresh", StripeCallbackController, :refresh diff --git a/test/algora/organizations_test.exs b/test/algora/organizations_test.exs index 13fd3f83b..8e3775361 100644 --- a/test/algora/organizations_test.exs +++ b/test/algora/organizations_test.exs @@ -187,4 +187,18 @@ defmodule Algora.OrganizationsTest do assert result3.org.handle == "piedpiperhq" end end + + describe "init_preview/1" do + test "creates a new user and org if they don't exist" do + assert {:ok, %{user: user, org: org}} = Algora.Organizations.init_preview("acme", "repo") + + assert is_nil(org.handle) + assert org.type == :organization + assert org.last_context == "repo/acme/repo" + + assert is_nil(user.handle) + assert user.type == :individual + assert user.last_context == "preview/#{org.id}/acme/repo" + end + end end diff --git a/test/algora_web/controllers/user_auth_test.exs b/test/algora_web/controllers/user_auth_test.exs index 687d9e480..8578d9e1c 100644 --- a/test/algora_web/controllers/user_auth_test.exs +++ b/test/algora_web/controllers/user_auth_test.exs @@ -81,4 +81,47 @@ defmodule AlgoraWeb.UserAuthTest do assert {:error, :invalid} = UserAuth.verify_login_code("", "test@example.com") end end + + describe "verify_preview_code/2" do + test "successfully verifies simple id token" do + id = "123" + code = UserAuth.sign_preview_code(id) + + assert {:ok, result} = UserAuth.verify_preview_code(code, id) + assert result == id + end + + test "rejects invalid id" do + id = "123" + code = UserAuth.sign_preview_code(id) + + assert {:error, :invalid_id} = UserAuth.verify_preview_code(code, "wrong") + end + + test "rejects tampered tokens" do + code = "tampered.token.here" + assert {:error, :invalid} = UserAuth.verify_preview_code(code, "123") + end + + test "rejects expired tokens" do + id = "123" + original_config = Application.get_env(:algora, :login_code) + Application.put_env(:algora, :login_code, Keyword.put(original_config, :ttl, 1)) + + code = UserAuth.sign_preview_code(id) + Process.sleep(1500) + + assert {:error, :expired} = UserAuth.verify_preview_code(code, id) + + Application.put_env(:algora, :login_code, original_config) + end + + test "handles nil input" do + assert {:error, :missing} = UserAuth.verify_preview_code(nil, "123") + end + + test "handles empty string input" do + assert {:error, :invalid} = UserAuth.verify_preview_code("", "123") + end + end end From cbe955202f83a5033b16cee2a4d91ccc6d9b6437 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 17:49:21 +0200 Subject: [PATCH 06/63] feat: add contributor management functionality - Introduced `Contributor` schema to track contributions associated with repositories and users. - Implemented `ensure_contributors/3` function to fetch and create contributors from GitHub. - Added `list_repository_contributors/2` function to retrieve contributors for a specific repository. - Updated `DashboardLive` to display contributors in the organization dashboard. - Created migration for the `contributors` table and added necessary indexes. - Enhanced GitHub client and behavior to support contributor retrieval. --- lib/algora/accounts/schemas/user.ex | 5 +- lib/algora/integrations/github/behaviour.ex | 1 + lib/algora/integrations/github/client.ex | 5 ++ lib/algora/integrations/github/github.ex | 3 + lib/algora/organizations/organizations.ex | 2 + lib/algora/workspace/schemas/contributor.ex | 51 +++++++++++++++ lib/algora/workspace/workspace.ex | 64 +++++++++++++++++++ lib/algora_web/components/core_components.ex | 2 +- lib/algora_web/components/ui/avatar.ex | 4 +- lib/algora_web/live/org/dashboard_live.ex | 17 ++++- .../20250320150931_create_contributors.exs | 20 ++++++ test/support/github_mock.ex | 5 ++ 12 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 lib/algora/workspace/schemas/contributor.ex create mode 100644 priv/repo/migrations/20250320150931_create_contributors.exs diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index 16057d41f..27810b625 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -320,8 +320,9 @@ defmodule Algora.Accounts.User do validate_inclusion(changeset, :timezone, Tzdata.zone_list()) end - defp type_from_provider(:github, "Organization"), do: :organization - defp type_from_provider(:github, _), do: :individual + def type_from_provider(:github, "Bot"), do: :bot + def type_from_provider(:github, "Organization"), do: :organization + def type_from_provider(:github, _), do: :individual def handle(%{handle: handle}) when is_binary(handle), do: handle def handle(%{provider_login: handle}), do: handle diff --git a/lib/algora/integrations/github/behaviour.ex b/lib/algora/integrations/github/behaviour.ex index 27ba71e8e..cff8f36c5 100644 --- a/lib/algora/integrations/github/behaviour.ex +++ b/lib/algora/integrations/github/behaviour.ex @@ -23,5 +23,6 @@ defmodule Algora.Github.Behaviour do {:ok, map()} | {:error, String.t()} @callback list_repository_events(token(), String.t(), String.t(), keyword()) :: {:ok, [map()]} | {:error, String.t()} @callback list_repository_comments(token(), String.t(), String.t(), keyword()) :: {:ok, [map()]} | {:error, String.t()} + @callback list_repository_contributors(token(), String.t(), String.t()) :: {:ok, [map()]} | {:error, String.t()} @callback add_labels(token(), String.t(), String.t(), integer(), [String.t()]) :: {:ok, [map()]} | {:error, String.t()} end diff --git a/lib/algora/integrations/github/client.ex b/lib/algora/integrations/github/client.ex index 240b4d0e1..0a8fcbd7a 100644 --- a/lib/algora/integrations/github/client.ex +++ b/lib/algora/integrations/github/client.ex @@ -241,6 +241,11 @@ defmodule Algora.Github.Client do fetch(access_token, "/repos/#{owner}/#{repo}/issues/comments#{build_query(opts)}") end + @impl true + def list_repository_contributors(access_token, owner, repo) do + fetch(access_token, "/repos/#{owner}/#{repo}/contributors") + end + @impl true def add_labels(access_token, owner, repo, number, labels) do fetch(access_token, "/repos/#{owner}/#{repo}/issues/#{number}/labels", "POST", %{ diff --git a/lib/algora/integrations/github/github.ex b/lib/algora/integrations/github/github.ex index 394d38bf5..2c95aa063 100644 --- a/lib/algora/integrations/github/github.ex +++ b/lib/algora/integrations/github/github.ex @@ -125,6 +125,9 @@ defmodule Algora.Github do def list_repository_comments(token, owner, repo, opts \\ []), do: client().list_repository_comments(token, owner, repo, opts) + @impl true + def list_repository_contributors(token, owner, repo), do: client().list_repository_contributors(token, owner, repo) + @impl true def add_labels(token, owner, repo, number, labels), do: client().add_labels(token, owner, repo, number, labels) end diff --git a/lib/algora/organizations/organizations.ex b/lib/algora/organizations/organizations.ex index 820bc508a..9783ada2d 100644 --- a/lib/algora/organizations/organizations.ex +++ b/lib/algora/organizations/organizations.ex @@ -236,6 +236,7 @@ defmodule Algora.Organizations do {:ok, _repo} = Workspace.ensure_repository(token, repo_owner, repo_name) {:ok, owner} = Workspace.ensure_user(token, repo_owner) + {:ok, _contributors} = Workspace.ensure_contributors(token, repo_owner, repo_name) Repo.transact(fn repo -> {:ok, org} = @@ -251,6 +252,7 @@ defmodule Algora.Organizations do repo.insert(%User{ type: :individual, id: Nanoid.generate(), + display_name: "You", last_context: "preview/#{org.id}/#{repo_owner}/#{repo_name}" }) diff --git a/lib/algora/workspace/schemas/contributor.ex b/lib/algora/workspace/schemas/contributor.ex new file mode 100644 index 000000000..6f8ebaccf --- /dev/null +++ b/lib/algora/workspace/schemas/contributor.ex @@ -0,0 +1,51 @@ +defmodule Algora.Workspace.Contributor do + @moduledoc false + use Algora.Schema + + alias Algora.Accounts.User + alias Algora.Workspace.Repository + + typed_schema "contributors" do + field :contributions, :integer, default: 0 + + belongs_to :repository, Repository + belongs_to :user, User + + timestamps() + end + + def github_user_changeset(meta) do + params = %{ + provider_id: to_string(meta["id"]), + provider_login: meta["login"], + type: User.type_from_provider(:github, meta["type"]), + display_name: meta["login"], + avatar_url: meta["avatar_url"], + github_url: meta["html_url"] + } + + %User{provider: "github", provider_meta: meta} + |> cast(params, [:provider_id, :provider_login, :type, :display_name, :avatar_url, :github_url]) + |> generate_id() + |> validate_required([:provider_id, :provider_login, :type]) + |> unique_constraint([:provider, :provider_id]) + end + + def changeset(contributor, params) do + contributor + |> cast(params, [:contributions, :repository_id, :user_id]) + |> validate_required([:repository_id, :user_id]) + |> foreign_key_constraint(:repository_id) + |> foreign_key_constraint(:user_id) + |> unique_constraint([:repository_id, :user_id]) + |> generate_id() + end + + def filter_by_repository_id(query, nil), do: query + + def filter_by_repository_id(query, repository_id) do + from c in query, + join: r in assoc(c, :repository), + where: r.id == ^repository_id + end +end diff --git a/lib/algora/workspace/workspace.ex b/lib/algora/workspace/workspace.ex index c7558c88c..8343bb9a1 100644 --- a/lib/algora/workspace/workspace.ex +++ b/lib/algora/workspace/workspace.ex @@ -9,6 +9,7 @@ defmodule Algora.Workspace do alias Algora.Repo alias Algora.Util alias Algora.Workspace.CommandResponse + alias Algora.Workspace.Contributor alias Algora.Workspace.Installation alias Algora.Workspace.Jobs alias Algora.Workspace.Repository @@ -447,4 +448,67 @@ defmodule Algora.Workspace do {:ok, command_response} end end + + def ensure_contributors(token, owner, repo) do + case list_repository_contributors(owner, repo) do + [] -> + with {:ok, repository} <- ensure_repository(token, owner, repo), + {:ok, contributors} <- Github.list_repository_contributors(token, owner, repo) do + Repo.transact(fn -> + Enum.reduce_while(contributors, {:ok, []}, fn contributor, {:ok, acc} -> + case create_contributor_from_github(repository, contributor) do + {:ok, created} -> {:cont, {:ok, [created | acc]}} + error -> {:halt, error} + end + end) + end) + end + + contributors -> + {:ok, contributors} + end + end + + defp ensure_user_by_contributor(contributor) do + case Repo.get_by(User, provider: "github", provider_id: to_string(contributor["id"])) do + %User{} = user -> + {:ok, user} + + nil -> + contributor + |> Contributor.github_user_changeset() + |> Repo.insert() + end + end + + def create_contributor_from_github(repository, contributor) do + with {:ok, user} <- ensure_user_by_contributor(contributor) do + %Contributor{} + |> Contributor.changeset(%{ + contributions: contributor["contributions"], + repository_id: repository.id, + user_id: user.id + }) + |> Repo.insert() + end + end + + def list_repository_contributors(repo_owner, repo_name) do + Repo.all( + from(c in Contributor, + join: r in assoc(c, :repository), + where: r.provider == "github", + where: r.name == ^repo_name, + join: ro in assoc(r, :user), + where: ro.provider_login == ^repo_owner, + join: u in assoc(c, :user), + select_merge: %{user: u}, + order_by: [desc: c.contributions, asc: c.inserted_at, asc: c.id] + ) + ) + end + + def fetch_contributor(repository_id, user_id) do + Repo.fetch_by(Contributor, repository_id: repository_id, user_id: user_id) + end end diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index 10e9402cd..54452931c 100644 --- a/lib/algora_web/components/core_components.ex +++ b/lib/algora_web/components/core_components.ex @@ -232,7 +232,7 @@ defmodule AlgoraWeb.CoreComponents do
{ctx.name}
-
@{ctx.handle}
+
@{ctx.handle}
diff --git a/lib/algora_web/components/ui/avatar.ex b/lib/algora_web/components/ui/avatar.ex index 7deecf34c..6d79f8da8 100644 --- a/lib/algora_web/components/ui/avatar.ex +++ b/lib/algora_web/components/ui/avatar.ex @@ -14,7 +14,7 @@ defmodule AlgoraWeb.Components.UI.Avatar do end attr :class, :string, default: nil - attr :src, :string, required: true + attr :src, :string, default: nil attr :rest, :global def avatar_image(assigns) do @@ -23,7 +23,7 @@ defmodule AlgoraWeb.Components.UI.Avatar do ~H""" repo -> + case String.split(repo, "/") do + [repo_owner, repo_name] -> Workspace.list_repository_contributors(repo_owner, repo_name) + _ -> [] + end + + _ -> + top_earners + end + {:ok, socket |> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user)) |> assign(:installations, installations) |> assign(:matching_devs, top_earners) + |> assign(:contributors, dbg(contributors)) |> assign(:bounties, bounties) |> assign(:has_more_bounties, false) |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) @@ -193,14 +207,13 @@ defmodule AlgoraWeb.Org.DashboardLive do <.section - :if={@matching_devs != []} title={"#{@current_org.name} Contributors"} subtitle="Engage your top contributors with tips or contract opportunities" >
- <%= for user <- @matching_devs do %> + <%= for %Contributor{user: user} <- dbg(@contributors) do %> <.matching_dev user={user} contracts={@contracts} current_org={@current_org} /> <% end %> diff --git a/priv/repo/migrations/20250320150931_create_contributors.exs b/priv/repo/migrations/20250320150931_create_contributors.exs new file mode 100644 index 000000000..d92b9645e --- /dev/null +++ b/priv/repo/migrations/20250320150931_create_contributors.exs @@ -0,0 +1,20 @@ +defmodule Algora.Repo.Migrations.CreateContributors do + use Ecto.Migration + + def change do + create table(:contributors) do + add :contributions, :integer, null: false, default: 0 + add :repository_id, references(:repositories, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:contributors, [:repository_id]) + create index(:contributors, [:user_id]) + + create unique_index(:contributors, [:repository_id, :user_id], + name: :contributors_repository_id_user_id_index + ) + end +end diff --git a/test/support/github_mock.ex b/test/support/github_mock.ex index 9771d5bc9..da610e6ad 100644 --- a/test/support/github_mock.ex +++ b/test/support/github_mock.ex @@ -136,6 +136,11 @@ defmodule Algora.Support.GithubMock do {:ok, []} end + @impl true + def list_repository_contributors(_access_token, _owner, _repo) do + {:ok, []} + end + @impl true def add_labels(_access_token, _owner, _repo, _number, _labels) do {:ok, []} From bb36fdcc8ab07a3fd9a47f2ca410de11c9c903a3 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 17:53:10 +0200 Subject: [PATCH 07/63] feat: add complete signup status to dashboard - Introduced `complete_signup_status/1` function to track user signup completion based on the presence of a user handle. - Updated `assign_achievements/1` to include the new signup status in the dashboard achievements. --- lib/algora_web/live/org/dashboard_live.ex | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 36e48bc49..fbec09b02 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -506,6 +506,7 @@ defmodule AlgoraWeb.Org.DashboardLive do defp assign_achievements(socket) do status_fns = [ {&personalize_status/1, "Personalize Algora", nil}, + {&complete_signup_status/1, "Complete signup", nil}, {&install_app_status/1, "Install the Algora app", nil}, {&create_bounty_status/1, "Create a bounty", nil}, {&reward_bounty_status/1, "Reward a bounty", nil}, @@ -531,6 +532,13 @@ defmodule AlgoraWeb.Org.DashboardLive do defp personalize_status(_socket), do: :completed + defp complete_signup_status(socket) do + case socket.assigns.current_user do + %User{handle: handle} when is_binary(handle) -> :completed + _ -> :upcoming + end + end + defp install_app_status(socket) do case socket.assigns.installations do [] -> :upcoming From 98d6f297a39ea4dba9194bd62e6dd1de3990b2a3 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 17:59:53 +0200 Subject: [PATCH 08/63] feat: implement contributor listing functionality - Added `list_contributors/1` function to retrieve contributors for a specific organization from GitHub. - Updated `DashboardLive` to display contributors based on the current organization. - Removed unused GitHub integration section from the dashboard for cleaner UI. --- lib/algora/workspace/workspace.ex | 14 ++++++++ lib/algora_web/live/org/dashboard_live.ex | 44 ++++++++++++----------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/lib/algora/workspace/workspace.ex b/lib/algora/workspace/workspace.ex index 8343bb9a1..dfb7c2c0e 100644 --- a/lib/algora/workspace/workspace.ex +++ b/lib/algora/workspace/workspace.ex @@ -508,6 +508,20 @@ defmodule Algora.Workspace do ) end + def list_contributors(repo_owner) do + Repo.all( + from(c in Contributor, + join: r in assoc(c, :repository), + where: r.provider == "github", + join: ro in assoc(r, :user), + where: ro.provider_login == ^repo_owner, + join: u in assoc(c, :user), + select_merge: %{user: u}, + order_by: [desc: c.contributions, asc: c.inserted_at, asc: c.id] + ) + ) + end + def fetch_contributor(repository_id, user_id) do Repo.fetch_by(Contributor, repository_id: repository_id, user_id: user_id) end diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index fbec09b02..c9cfeb2eb 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -45,11 +45,11 @@ defmodule AlgoraWeb.Org.DashboardLive do "repo/" <> repo -> case String.split(repo, "/") do [repo_owner, repo_name] -> Workspace.list_repository_contributors(repo_owner, repo_name) - _ -> [] + _ -> Workspace.list_contributors(current_org.provider_login) end _ -> - top_earners + Workspace.list_contributors(current_org.provider_login) end {:ok, @@ -57,7 +57,7 @@ defmodule AlgoraWeb.Org.DashboardLive do |> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user)) |> assign(:installations, installations) |> assign(:matching_devs, top_earners) - |> assign(:contributors, dbg(contributors)) + |> assign(:contributors, contributors) |> assign(:bounties, bounties) |> assign(:has_more_bounties, false) |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) @@ -188,25 +188,8 @@ defmodule AlgoraWeb.Org.DashboardLive do - <.section :if={@installations == []}> - <.card> - <.card_header> - <.card_title>GitHub Integration - <.card_description :if={@installations == []}> - Install the Algora app to enable slash commands in your GitHub repositories - - - <.card_content> -
- <.button phx-click="install_app" class="ml-auto gap-2"> - Install GitHub App - -
- - - - <.section + :if={@contributors != []} title={"#{@current_org.name} Contributors"} subtitle="Engage your top contributors with tips or contract opportunities" > @@ -238,6 +221,7 @@ defmodule AlgoraWeb.Org.DashboardLive do <.section + :if={@bounties != []} title={"#{@current_org.name} Bounties"} subtitle="List of bounties posted by #{@current_org.name}" > @@ -301,6 +285,24 @@ defmodule AlgoraWeb.Org.DashboardLive do {create_tip(assigns)} + + <.section :if={@installations == [] && 1 == 2}> + <.card> + <.card_header> + <.card_title>GitHub Integration + <.card_description :if={@installations == []}> + Install the Algora app to enable slash commands in your GitHub repositories + + + <.card_content> +
+ <.button phx-click="install_app" class="ml-auto gap-2"> + Install GitHub App + +
+ + + {sidebar(assigns)} From 0742e73d0b6a7d960e631f927c863cf2f987275a Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 18:21:00 +0200 Subject: [PATCH 09/63] feat: enhance user dashboard with achievement tracking - Updated `achievement_todo` functions to handle various achievement statuses, including new conditions for GitHub app installation. - Modified `assign_achievements` to include achievement IDs for better tracking. - Adjusted the dashboard rendering to display achievement todos alongside existing achievements. --- lib/algora/accounts/schemas/user.ex | 2 +- lib/algora_web/live/org/dashboard_live.ex | 43 ++++++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index 27810b625..4a16b65da 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -270,7 +270,7 @@ defmodule Algora.Accounts.User do def validate_handle(changeset) do reserved_words = - ~w(personal org admin support help security team staff official auth tip home dashboard bounties community user payment claims orgs projects jobs leaderboard onboarding pricing developers companies contracts community blog docs open hiring sdk api) + ~w(personal org admin support help security team staff official auth tip home dashboard bounties community user payment claims orgs projects jobs leaderboard onboarding pricing developers companies contracts community blog docs open hiring sdk api repo go preview) changeset |> validate_format(:handle, ~r/^[a-zA-Z0-9_-]{2,32}$/) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index c9cfeb2eb..a31cc9d34 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -505,11 +505,37 @@ defmodule AlgoraWeb.Org.DashboardLive do assign(socket, :contracts, contracts) end + defp achievement_todo(%{achievement: %{status: status}} = assigns) when status != :current do + ~H""" + """ + end + + defp achievement_todo(%{achievement: %{id: :complete_signup_status}} = assigns) do + ~H""" + """ + end + + defp achievement_todo(%{achievement: %{id: :install_app_status}} = assigns) do + ~H""" + <.button phx-click="install_app" class="ml-auto gap-2"> + Install GitHub App + + """ + end + + defp achievement_todo(assigns) do + ~H""" + """ + end + defp assign_achievements(socket) do + current_org = socket.assigns.current_org + status_fns = [ {&personalize_status/1, "Personalize Algora", nil}, {&complete_signup_status/1, "Complete signup", nil}, - {&install_app_status/1, "Install the Algora app", nil}, + {&connect_github_status/1, "Connect GitHub", nil}, + {&install_app_status/1, "Install Algora in #{current_org.name}", nil}, {&create_bounty_status/1, "Create a bounty", nil}, {&reward_bounty_status/1, "Reward a bounty", nil}, {&share_with_friend_status/1, "Share Algora with a friend", nil} @@ -517,13 +543,14 @@ defmodule AlgoraWeb.Org.DashboardLive do {achievements, _} = Enum.reduce_while(status_fns, {[], false}, fn {status_fn, name, path}, {acc, found_current} -> + id = Function.info(status_fn)[:name] status = status_fn.(socket) result = cond do - found_current -> {acc ++ [%{status: status, name: name, path: path}], found_current} - status == :completed -> {acc ++ [%{status: status, name: name, path: path}], false} - true -> {acc ++ [%{status: :current, name: name, path: path}], true} + found_current -> {acc ++ [%{id: id, status: status, name: name, path: path}], found_current} + status == :completed -> {acc ++ [%{id: id, status: status, name: name, path: path}], false} + true -> {acc ++ [%{id: id, status: :current, name: name, path: path}], true} end {:cont, result} @@ -541,6 +568,13 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + defp connect_github_status(socket) do + case socket.assigns.current_user do + %User{provider_login: login} when is_binary(login) -> :completed + _ -> :upcoming + end + end + defp install_app_status(socket) do case socket.assigns.installations do [] -> :upcoming @@ -779,6 +813,7 @@ defmodule AlgoraWeb.Org.DashboardLive do <%= for achievement <- @achievements do %>
  • <.achievement achievement={achievement} /> + <.achievement_todo achievement={achievement} />
  • <% end %> From 00792b3d60ec23c3a8d7d76f85d922f2ca325102 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 18:37:25 +0200 Subject: [PATCH 10/63] feat: implement login code functionality in dashboard - Added event handling for sending and verifying login codes via email. - Introduced a new form for users to input their email and receive a login code. - Enhanced the dashboard to manage login state and display appropriate forms based on the login process. - Integrated Swoosh for email delivery of login codes. --- lib/algora_web/live/org/dashboard_live.ex | 111 +++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index a31cc9d34..af285e51d 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -22,6 +22,7 @@ defmodule AlgoraWeb.Org.DashboardLive do alias AlgoraWeb.Forms.BountyForm alias AlgoraWeb.Forms.ContractForm alias AlgoraWeb.Forms.TipForm + alias Swoosh.Email require Logger @@ -66,6 +67,8 @@ defmodule AlgoraWeb.Org.DashboardLive do |> assign(:contract_form, to_form(ContractForm.changeset(%ContractForm{}, %{}))) |> assign(:show_contract_modal, false) |> assign(:selected_developer, nil) + |> assign(:secret_code, nil) + |> assign_login_form(User.login_changeset(%User{}, %{})) |> assign_payable_bounties() |> assign_contracts() |> assign_achievements()} @@ -471,6 +474,70 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + def handle_event("send_login_code", %{"user" => %{"email" => email}}, socket) do + code = Nanoid.generate() + + changeset = User.login_changeset(%User{}, %{}) + + case send_login_code_to_user(socket.assigns.current_user, code) do + {:ok, _id} -> + {:noreply, + socket + |> assign(:secret_code, code) + |> assign(:email, email) + |> assign_login_form(changeset)} + + {:error, reason} -> + Logger.error("Failed to send login code to #{email}: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "We had trouble sending mail to #{email}. Please try again")} + end + end + + def handle_event("send_login_code", %{"user" => %{"login_code" => code}}, socket) do + if Plug.Crypto.secure_compare(code, socket.assigns.secret_code) do + # TODO: update user + # email = socket.assigns.email + # last_context = ... + + {:noreply, put_flash(socket, :info, "Logged in successfully!")} + else + throttle() + {:noreply, put_flash(socket, :error, "Invalid login code")} + end + end + + defp throttle, do: :timer.sleep(1000) + + defp assign_login_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, :login_form, to_form(changeset)) + end + + @from_name "Algora" + @from_email "info@algora.io" + + defp send_login_code_to_user(user, code) do + email = + Email.new() + |> Email.to({user.display_name, user.email}) + |> Email.from({@from_name, @from_email}) + |> Email.subject("Login code for Algora") + |> Email.text_body(""" + Here is your login code for Algora! + + #{code} + + If you didn't request this link, you can safely ignore this email. + + -------------------------------------------------------------------------------- + + For correspondence, please email the Algora founders at ioannis@algora.io and zafer@algora.io + + © 2025 Algora PBC. + """) + + Algora.Mailer.deliver(email) + end + defp assign_payable_bounties(socket) do org = socket.assigns.current_org @@ -512,6 +579,42 @@ defmodule AlgoraWeb.Org.DashboardLive do defp achievement_todo(%{achievement: %{id: :complete_signup_status}} = assigns) do ~H""" + <.simple_form + :if={!@secret_code} + for={@login_form} + id="send_login_code_form" + phx-submit="send_login_code" + > + <.input + field={@login_form[:email]} + type="email" + label="Email" + placeholder="you@example.com" + required + /> + <.button phx-disable-with="Signing in..." class="w-full py-5"> + Sign in + + + <.simple_form + :if={@secret_code} + for={@login_form} + id="send_login_code_form" + phx-submit="send_login_code" + > + <.input field={@login_form[:login_code]} type="text" label="Login code" required /> + <.button phx-disable-with="Signing in..." class="w-full py-5"> + Submit + + + """ + end + + defp achievement_todo(%{achievement: %{id: :connect_github_status}} = assigns) do + ~H""" + <.button :if={!@current_user.provider_login} href={@oauth_url} class="ml-auto gap-2"> + Connect GitHub + """ end @@ -811,9 +914,13 @@ defmodule AlgoraWeb.Org.DashboardLive do
    - <%= for %Contributor{user: user} <- dbg(@contributors) do %> + <%= for %Contributor{user: user} <- @contributors do %> <.matching_dev user={user} contracts={@contracts} current_org={@current_org} /> <% end %> @@ -479,7 +480,7 @@ defmodule AlgoraWeb.Org.DashboardLive do changeset = User.login_changeset(%User{}, %{}) - case send_login_code_to_user(socket.assigns.current_user, code) do + case send_login_code_to_user(email, code) do {:ok, _id} -> {:noreply, socket @@ -495,9 +496,14 @@ defmodule AlgoraWeb.Org.DashboardLive do def handle_event("send_login_code", %{"user" => %{"login_code" => code}}, socket) do if Plug.Crypto.secure_compare(code, socket.assigns.secret_code) do - # TODO: update user - # email = socket.assigns.email - # last_context = ... + handle = + socket.assigns.email + |> Organizations.generate_handle_from_email() + |> Organizations.ensure_unique_handle() + + socket.assigns.current_user + |> Ecto.Changeset.change(handle: handle, email: socket.assigns.email) + |> Repo.update() {:noreply, put_flash(socket, :info, "Logged in successfully!")} else @@ -515,10 +521,10 @@ defmodule AlgoraWeb.Org.DashboardLive do @from_name "Algora" @from_email "info@algora.io" - defp send_login_code_to_user(user, code) do + defp send_login_code_to_user(email, code) do email = Email.new() - |> Email.to({user.display_name, user.email}) + |> Email.to(email) |> Email.from({@from_name, @from_email}) |> Email.subject("Login code for Algora") |> Email.text_body(""" From 5529520ce53c5054e35d87d51cade27707eba2a5 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 19:00:20 +0200 Subject: [PATCH 12/63] fix: update user handle and email assignment in dashboard - Changed the user update logic to capture the updated user after changing the handle and email. - Enhanced the socket assignment to ensure the current user is updated after a successful login. - Added current user and OAuth URL to the achievement todo component for improved context. --- lib/algora_web/live/org/dashboard_live.ex | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 3fd3e13c6..dbc7c9006 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -501,11 +501,12 @@ defmodule AlgoraWeb.Org.DashboardLive do |> Organizations.generate_handle_from_email() |> Organizations.ensure_unique_handle() - socket.assigns.current_user - |> Ecto.Changeset.change(handle: handle, email: socket.assigns.email) - |> Repo.update() + {:ok, user} = + socket.assigns.current_user + |> Ecto.Changeset.change(handle: handle, email: socket.assigns.email) + |> Repo.update() - {:noreply, put_flash(socket, :info, "Logged in successfully!")} + {:noreply, socket |> assign(:current_user, user) |> put_flash(:info, "Logged in successfully!")} else throttle() {:noreply, put_flash(socket, :error, "Invalid login code")} @@ -924,6 +925,8 @@ defmodule AlgoraWeb.Org.DashboardLive do <.achievement achievement={achievement} /> <.achievement_todo achievement={achievement} + current_user={@current_user} + oauth_url={@oauth_url} secret_code={@secret_code} login_form={@login_form} /> From 850ba4360340d6e8b3d3bab2b8f11d450ad4433d Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 19:06:47 +0200 Subject: [PATCH 13/63] fix: update GitHub authorization URL in achievement todo component - Changed the OAuth URL in the achievement todo component to use the `Github.authorize_url()` function for improved clarity and maintainability. - Removed the unused `oauth_url` assignment from the component's parameters to streamline the code. --- lib/algora_web/live/org/dashboard_live.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index dbc7c9006..e4f687953 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -619,7 +619,7 @@ defmodule AlgoraWeb.Org.DashboardLive do defp achievement_todo(%{achievement: %{id: :connect_github_status}} = assigns) do ~H""" - <.button :if={!@current_user.provider_login} href={@oauth_url} class="ml-auto gap-2"> + <.button :if={!@current_user.provider_login} href={Github.authorize_url()} class="ml-auto gap-2"> Connect GitHub """ @@ -926,7 +926,6 @@ defmodule AlgoraWeb.Org.DashboardLive do <.achievement_todo achievement={achievement} current_user={@current_user} - oauth_url={@oauth_url} secret_code={@secret_code} login_form={@login_form} /> From 5544f5421e038a312c6ef3ed27619f00b137c659 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 20:03:12 +0200 Subject: [PATCH 14/63] refactor: improve GitHub user registration and update logic - Modified the `register_github_user` function to accept the current user as a parameter, enhancing the user registration flow. - Updated user handling to ensure proper updates and deletions of existing GitHub identities during registration. - Removed the unused `update_github_token` function to streamline the codebase. - Cleaned up the dashboard live view by removing unnecessary conditional rendering for bounties. --- lib/algora/accounts/accounts.ex | 63 +++++++++++-------- .../controllers/oauth_callback_controller.ex | 2 +- lib/algora_web/live/org/dashboard_live.ex | 1 - 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index e059c1439..e4f16dad0 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -1,6 +1,5 @@ defmodule Algora.Accounts do @moduledoc false - import Ecto.Changeset import Ecto.Query alias Algora.Accounts.Identity @@ -246,7 +245,7 @@ defmodule Algora.Accounts do @doc """ Registers a user from their GitHub information. """ - def register_github_user(primary_email, info, emails, token) do + def register_github_user(current_user, primary_email, info, emails, token) do query = from(u in User, left_join: i in Identity, @@ -257,10 +256,18 @@ defmodule Algora.Accounts do select: {u, i} ) - case Repo.one(query) do + account = + case Repo.all(query) do + [] -> nil + [{user, identity}] -> {user, identity} + records -> Enum.find(records, fn {user, _} -> user.id == current_user.id end) + end + + dbg(account) + + case account do nil -> create_user(info, primary_email, emails, token) - {user, nil} -> update_user(user, info, primary_email, emails, token) - {user, _identity} -> update_github_token(user, token) + {user, _identity} -> update_user(user, info, primary_email, emails, token) end end @@ -275,14 +282,31 @@ defmodule Algora.Accounts do end def update_user(user, info, primary_email, emails, token) do - with {:ok, _} <- - user - |> Identity.github_registration_changeset(info, primary_email, emails, token) - |> Repo.insert() do - user - |> User.github_registration_changeset(info, primary_email, emails, token) - |> Repo.update() - end + Repo.transact(fn -> + Repo.update_all( + from(u in User, + where: u.provider == "github", + where: u.provider_id == ^to_string(info["id"]) + ), + set: [provider: nil, provider_id: nil, provider_login: nil, provider_meta: nil] + ) + + Repo.delete_all( + from(i in Identity, + where: i.provider == "github", + where: i.provider_id == ^to_string(info["id"]) + ) + ) + + with {:ok, _} <- + user + |> Identity.github_registration_changeset(info, primary_email, emails, token) + |> Repo.insert() do + user + |> User.github_registration_changeset(info, primary_email, emails, token) + |> Repo.update() + end + end) end # def get_user_by_provider_email(provider, email) when provider in [:github] do @@ -352,19 +376,6 @@ defmodule Algora.Accounts do end end - defp update_github_token(%User{} = user, new_token) do - identity = - Repo.one!(from(i in Identity, where: i.user_id == ^user.id and i.provider == "github")) - - {:ok, _} = - identity - |> change() - |> put_change(:provider_token, new_token) - |> Repo.update() - - {:ok, Repo.preload(user, :identities, force: true)} - end - def last_context(nil), do: "nil" def last_context(%User{last_context: nil} = user) do diff --git a/lib/algora_web/controllers/oauth_callback_controller.ex b/lib/algora_web/controllers/oauth_callback_controller.ex index 0aa17f1a5..8124010da 100644 --- a/lib/algora_web/controllers/oauth_callback_controller.ex +++ b/lib/algora_web/controllers/oauth_callback_controller.ex @@ -33,7 +33,7 @@ defmodule AlgoraWeb.OAuthCallbackController do with {:ok, data} <- res, {:ok, info} <- Github.OAuth.exchange_access_token(code: code, state: state), %{info: info, primary_email: primary, emails: emails, token: token} = info, - {:ok, user} <- Accounts.register_github_user(primary, info, emails, token) do + {:ok, user} <- Accounts.register_github_user(conn.assigns[:current_user], primary, info, emails, token) do if socket_id do Phoenix.PubSub.broadcast(Algora.PubSub, "auth:#{socket_id}", {:authenticated, user}) end diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index e4f687953..4684c58d8 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -225,7 +225,6 @@ defmodule AlgoraWeb.Org.DashboardLive do <.section - :if={@bounties != []} title={"#{@current_org.name} Bounties"} subtitle="List of bounties posted by #{@current_org.name}" > From e4f3d6cadb9fe26dd8d624e8d95accd71de97282 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 20:12:50 +0200 Subject: [PATCH 15/63] refactor: improve user update logic for GitHub accounts - Enhanced the `update_user` function to utilize Ecto changesets for updating GitHub user information, improving clarity and maintainability. - Streamlined the deletion of associated identities and updated user references in related repositories and contributors. - Added debugging output in the OAuth callback controller for better traceability during the authentication process. - Updated the dashboard live view to include achievements assignment upon successful login, enhancing user experience. --- lib/algora/accounts/accounts.ex | 28 +++++++++++-------- .../controllers/oauth_callback_controller.ex | 2 ++ lib/algora_web/live/org/dashboard_live.ex | 10 +++++-- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index e4f16dad0..404a67146 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -1,5 +1,6 @@ defmodule Algora.Accounts do @moduledoc false + import Ecto.Changeset import Ecto.Query alias Algora.Accounts.Identity @@ -283,20 +284,23 @@ defmodule Algora.Accounts do def update_user(user, info, primary_email, emails, token) do Repo.transact(fn -> - Repo.update_all( - from(u in User, - where: u.provider == "github", - where: u.provider_id == ^to_string(info["id"]) - ), - set: [provider: nil, provider_id: nil, provider_login: nil, provider_meta: nil] - ) + if old_user = Repo.get_by(User, provider: "github", provider_id: to_string(info["id"])) do + old_user + |> change(provider: nil, provider_id: nil, provider_login: nil, provider_meta: nil) + |> Repo.update() - Repo.delete_all( - from(i in Identity, - where: i.provider == "github", - where: i.provider_id == ^to_string(info["id"]) + Repo.delete_all(from(i in Identity, where: i.user_id == ^old_user.id)) + + Repo.update_all( + from(r in Algora.Workspace.Repository, where: r.user_id == ^old_user.id), + set: [user_id: user.id] ) - ) + + Repo.update_all( + from(c in Algora.Workspace.Contributor, where: c.user_id == ^old_user.id), + set: [user_id: user.id] + ) + end with {:ok, _} <- user diff --git a/lib/algora_web/controllers/oauth_callback_controller.ex b/lib/algora_web/controllers/oauth_callback_controller.ex index 8124010da..059b53dea 100644 --- a/lib/algora_web/controllers/oauth_callback_controller.ex +++ b/lib/algora_web/controllers/oauth_callback_controller.ex @@ -30,6 +30,8 @@ defmodule AlgoraWeb.OAuthCallbackController do type = if(socket_id, do: :popup, else: :redirect) + dbg(res) + with {:ok, data} <- res, {:ok, info} <- Github.OAuth.exchange_access_token(code: code, state: state), %{info: info, primary_email: primary, emails: emails, token: token} = info, diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 4684c58d8..449d18faa 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -505,7 +505,11 @@ defmodule AlgoraWeb.Org.DashboardLive do |> Ecto.Changeset.change(handle: handle, email: socket.assigns.email) |> Repo.update() - {:noreply, socket |> assign(:current_user, user) |> put_flash(:info, "Logged in successfully!")} + {:noreply, + socket + |> assign(:current_user, user) + |> assign_achievements() + |> put_flash(:info, "Logged in successfully!")} else throttle() {:noreply, put_flash(socket, :error, "Invalid login code")} @@ -598,8 +602,8 @@ defmodule AlgoraWeb.Org.DashboardLive do placeholder="you@example.com" required /> - <.button phx-disable-with="Signing in..." class="w-full py-5"> - Sign in + <.button phx-disable-with="Signing up..." class="w-full py-5"> + Sign up <.simple_form From 48f297cf0468481c6d58dc167b0450372b5226e8 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 20:20:26 +0200 Subject: [PATCH 16/63] refactor: enhance user login handling in dashboard - Updated the user login logic to check for existing users by email before updating their handle and email. - Implemented redirection for users with existing accounts to generate a login token, improving the login flow. - Maintained the assignment of achievements and flash messages upon successful login, enhancing user experience. --- lib/algora_web/live/org/dashboard_live.ex | 31 +++++++++++++++-------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 449d18faa..e4f308a31 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -500,16 +500,27 @@ defmodule AlgoraWeb.Org.DashboardLive do |> Organizations.generate_handle_from_email() |> Organizations.ensure_unique_handle() - {:ok, user} = - socket.assigns.current_user - |> Ecto.Changeset.change(handle: handle, email: socket.assigns.email) - |> Repo.update() - - {:noreply, - socket - |> assign(:current_user, user) - |> assign_achievements() - |> put_flash(:info, "Logged in successfully!")} + case Repo.get_by(User, email: socket.assigns.email) do + nil -> + {:ok, user} = + socket.assigns.current_user + |> Ecto.Changeset.change(handle: handle, email: socket.assigns.email) + |> Repo.update() + + {:noreply, + socket + |> assign(:current_user, user) + |> assign_achievements() + |> put_flash(:info, "Logged in successfully!")} + + user -> + token = AlgoraWeb.UserAuth.generate_login_code(user.email) + + {:noreply, + socket + |> redirect(to: AlgoraWeb.UserAuth.login_path(user.email, token)) + |> put_flash(:info, "Logged in successfully!")} + end else throttle() {:noreply, put_flash(socket, :error, "Invalid login code")} From abf9ce66a2b072b074dcebc9365d75f0a2265494 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 21:11:54 +0200 Subject: [PATCH 17/63] refactor: enhance user migration logic and installation handling - Introduced a new `migrate_user` function to streamline the migration of user data across related entities when updating user accounts. - Updated the `update_user` function to utilize the new migration logic, improving code clarity and maintainability. - Refactored the installation callback controller to enhance the handling of user installations and ensure proper context management. - Improved the dashboard live view by correcting the subtitle rendering for bounties, enhancing UI consistency. --- lib/algora/accounts/accounts.ex | 44 +++++++--- .../installation_callback_controller.ex | 81 ++++++++++++++++--- lib/algora_web/live/org/dashboard_live.ex | 2 +- 3 files changed, 102 insertions(+), 25 deletions(-) diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index 404a67146..9c4b053ee 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -11,6 +11,9 @@ defmodule Algora.Accounts do alias Algora.Organizations.Member alias Algora.Payments.Transaction alias Algora.Repo + alias Algora.Workspace.Contributor + alias Algora.Workspace.Installation + alias Algora.Workspace.Repository require Algora.SQL @@ -282,6 +285,35 @@ defmodule Algora.Accounts do |> Repo.insert() end + def migrate_user(old_user, new_user) do + Repo.delete_all(from(i in Identity, where: i.user_id == ^old_user.id)) + + Repo.update_all( + from(r in Repository, where: r.user_id == ^old_user.id), + set: [user_id: new_user.id] + ) + + Repo.update_all( + from(c in Contributor, where: c.user_id == ^old_user.id), + set: [user_id: new_user.id] + ) + + Repo.update_all( + from(i in Installation, where: i.owner_id == ^old_user.id), + set: [owner_id: new_user.id] + ) + + Repo.update_all( + from(i in Installation, where: i.provider_user_id == ^old_user.id), + set: [provider_user_id: new_user.id] + ) + + Repo.update_all( + from(i in Installation, where: i.connected_user_id == ^old_user.id), + set: [connected_user_id: new_user.id] + ) + end + def update_user(user, info, primary_email, emails, token) do Repo.transact(fn -> if old_user = Repo.get_by(User, provider: "github", provider_id: to_string(info["id"])) do @@ -289,17 +321,7 @@ defmodule Algora.Accounts do |> change(provider: nil, provider_id: nil, provider_login: nil, provider_meta: nil) |> Repo.update() - Repo.delete_all(from(i in Identity, where: i.user_id == ^old_user.id)) - - Repo.update_all( - from(r in Algora.Workspace.Repository, where: r.user_id == ^old_user.id), - set: [user_id: user.id] - ) - - Repo.update_all( - from(c in Algora.Workspace.Contributor, where: c.user_id == ^old_user.id), - set: [user_id: user.id] - ) + migrate_user(old_user, user) end with {:ok, _} <- diff --git a/lib/algora_web/controllers/installation_callback_controller.ex b/lib/algora_web/controllers/installation_callback_controller.ex index 3af6db697..7b3dcdc5b 100644 --- a/lib/algora_web/controllers/installation_callback_controller.ex +++ b/lib/algora_web/controllers/installation_callback_controller.ex @@ -1,10 +1,18 @@ defmodule AlgoraWeb.InstallationCallbackController do use AlgoraWeb, :controller + import Ecto.Changeset + import Ecto.Query + alias Algora.Accounts + alias Algora.Accounts.User alias Algora.Github alias Algora.Organizations + alias Algora.Organizations.Member + alias Algora.Repo + alias Algora.Util alias Algora.Workspace + alias AlgoraWeb.UserAuth require Logger @@ -23,10 +31,10 @@ defmodule AlgoraWeb.InstallationCallbackController do :info, "Installation request submitted! The Algora app will be activated upon approval from your organization administrator." ) - |> redirect(to: redirect_url(conn)) + |> redirect(to: UserAuth.signed_in_path(conn)) {:error, _reason} -> - redirect(conn, to: redirect_url(conn)) + redirect(conn, to: UserAuth.signed_in_path(conn)) end end @@ -49,33 +57,80 @@ defmodule AlgoraWeb.InstallationCallbackController do defp handle_installation(conn, setup_action, installation_id) do user = conn.assigns.current_user - case do_handle_installation(user, installation_id) do - {:ok, _org} -> + case do_handle_installation(conn, user, installation_id) do + {:ok, conn} -> conn |> put_flash(:info, if(setup_action == :install, do: "Installation successful!", else: "Installation updated!")) - |> redirect(to: redirect_url(conn)) + |> redirect(to: UserAuth.signed_in_path(conn)) {:error, error} -> Logger.error("❌ Installation callback failed: #{inspect(error)}") conn |> put_flash(:error, "#{inspect(error)}") - |> redirect(to: redirect_url(conn)) + |> redirect(to: UserAuth.signed_in_path(conn)) end end - defp do_handle_installation(user, installation_id) do + defp do_handle_installation(conn, user, installation_id) do # TODO: replace :last_context with a new :last_installation_target field # TODO: handle nil user # TODO: handle nil last_context with {:ok, access_token} <- Accounts.get_access_token(user), {:ok, installation} <- Github.find_installation(access_token, installation_id), - {:ok, provider_user} <- Workspace.ensure_user(access_token, installation["account"]["login"]), - {:ok, org} <- Organizations.fetch_org_by(handle: user.last_context), - {:ok, _} <- Workspace.upsert_installation(installation, user, org, provider_user) do - {:ok, org} + {:ok, provider_user} <- Workspace.ensure_user(access_token, installation["account"]["login"]) do + case user.last_context do + "preview/" <> ctx -> + case String.split(ctx, "/") do + [id, _repo_owner, _repo_name] -> + existing_org = + Repo.one( + from(u in User, + where: u.provider == "github", + where: u.provider_id == ^to_string(installation["account"]["id"]) + ) + ) + + {:ok, org} = + case existing_org do + %User{} = org -> {:ok, org} + nil -> Repo.fetch(User, id) + end + + {:ok, _member} = + case Repo.get_by(Member, user_id: user.id, org_id: org.id) do + %Member{} = member -> {:ok, member} + nil -> Repo.insert(%Member{id: Nanoid.generate(), user: user, org: org, role: :admin}) + end + + {:ok, org} = + org + |> change( + handle: Organizations.ensure_unique_org_handle(installation["account"]["login"]), + provider: "github", + provider_id: to_string(installation["account"]["id"]), + provider_meta: Util.normalize_struct(installation["account"]) + ) + |> Repo.update() + + {:ok, user} = + user + |> change(last_context: org.handle) + |> Repo.update() + + {:ok, _} = Workspace.upsert_installation(installation, user, org, provider_user) + + {:ok, UserAuth.put_current_user(conn, user)} + + _ -> + {:error, :invalid_last_context} + end + + last_context -> + {:ok, org} = Organizations.fetch_org_by(handle: last_context) + {:ok, _} = Workspace.upsert_installation(installation, user, org, provider_user) + {:ok, conn} + end end end - - defp redirect_url(conn), do: ~p"/org/#{Accounts.last_context(conn.assigns.current_user)}" end diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index e4f308a31..848204942 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -226,7 +226,7 @@ defmodule AlgoraWeb.Org.DashboardLive do <.section title={"#{@current_org.name} Bounties"} - subtitle="List of bounties posted by #{@current_org.name}" + subtitle={"List of bounties posted by #{@current_org.name}"} >
    From 4bc551239f84d303825bb209dda2a5d0b9805027 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 20 Mar 2025 21:18:00 +0200 Subject: [PATCH 18/63] add todo --- lib/algora/accounts/accounts.ex | 11 +++++++---- .../controllers/oauth_callback_controller.ex | 2 -- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index 9c4b053ee..ec63fc5b9 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -267,12 +267,15 @@ defmodule Algora.Accounts do records -> Enum.find(records, fn {user, _} -> user.id == current_user.id end) end + # TODO: figure out why name is not updating dbg(account) - case account do - nil -> create_user(info, primary_email, emails, token) - {user, _identity} -> update_user(user, info, primary_email, emails, token) - end + dbg( + case account do + nil -> create_user(info, primary_email, emails, token) + {user, _identity} -> update_user(user, info, primary_email, emails, token) + end + ) end def register_org(params) do diff --git a/lib/algora_web/controllers/oauth_callback_controller.ex b/lib/algora_web/controllers/oauth_callback_controller.ex index 059b53dea..8124010da 100644 --- a/lib/algora_web/controllers/oauth_callback_controller.ex +++ b/lib/algora_web/controllers/oauth_callback_controller.ex @@ -30,8 +30,6 @@ defmodule AlgoraWeb.OAuthCallbackController do type = if(socket_id, do: :popup, else: :redirect) - dbg(res) - with {:ok, data} <- res, {:ok, info} <- Github.OAuth.exchange_access_token(code: code, state: state), %{info: info, primary_email: primary, emails: emails, token: token} = info, From fded97fd72e761bbc13b8ee227bf36bfc189bed5 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 11:15:09 +0200 Subject: [PATCH 19/63] refactor: streamline user account creation and update logic - Removed debugging statements and simplified the account handling logic in the `Algora.Accounts` module. - Updated the user registration process to directly use the provided user information for display names. - Enhanced the user update logic in the `Algora.Accounts.User` module to conditionally set parameters based on the presence of a provider ID, improving clarity and maintainability. - Added an alias for the `Organizations` module in the onboarding live view for better organization of dependencies. --- lib/algora/accounts/accounts.ex | 13 +++----- lib/algora/accounts/schemas/user.ex | 44 +++++++++++++++++++-------- lib/algora_web/live/onboarding/org.ex | 1 + 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index ec63fc5b9..4afca47fd 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -267,15 +267,10 @@ defmodule Algora.Accounts do records -> Enum.find(records, fn {user, _} -> user.id == current_user.id end) end - # TODO: figure out why name is not updating - dbg(account) - - dbg( - case account do - nil -> create_user(info, primary_email, emails, token) - {user, _identity} -> update_user(user, info, primary_email, emails, token) - end - ) + case account do + nil -> create_user(info, primary_email, emails, token) + {user, _identity} -> update_user(user, info, primary_email, emails, token) + end end def register_org(params) do diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index 4a16b65da..fffcd284e 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -129,7 +129,7 @@ defmodule Algora.Accounts.User do params = %{ "handle" => info["login"], "email" => primary_email, - "display_name" => get_change(identity_changeset, :provider_name), + "display_name" => info["name"], "bio" => info["bio"], "location" => info["location"], "avatar_url" => info["avatar_url"], @@ -176,18 +176,36 @@ defmodule Algora.Accounts.User do Identity.github_registration_changeset(user, info, primary_email, emails, token) if identity_changeset.valid? do - params = %{ - "display_name" => user.display_name || get_change(identity_changeset, :provider_name), - "bio" => user.bio || info["bio"], - "location" => user.location || info["location"], - "avatar_url" => user.avatar_url || info["avatar_url"], - "website_url" => user.website_url || info["blog"], - "github_url" => user.github_url || info["html_url"], - "provider" => "github", - "provider_id" => to_string(info["id"]), - "provider_login" => info["login"], - "provider_meta" => info - } + params = + case user.provider_id do + nil -> + %{ + "display_name" => info["name"], + "bio" => info["bio"], + "location" => info["location"], + "avatar_url" => info["avatar_url"], + "website_url" => info["blog"], + "github_url" => info["html_url"], + "provider" => "github", + "provider_id" => to_string(info["id"]), + "provider_login" => info["login"], + "provider_meta" => info + } + + _ -> + %{ + "display_name" => user.display_name || info["name"], + "bio" => user.bio || info["bio"], + "location" => user.location || info["location"], + "avatar_url" => user.avatar_url || info["avatar_url"], + "website_url" => user.website_url || info["blog"], + "github_url" => user.github_url || info["html_url"], + "provider" => "github", + "provider_id" => to_string(info["id"]), + "provider_login" => info["login"], + "provider_meta" => info + } + end user |> cast(params, [ diff --git a/lib/algora_web/live/onboarding/org.ex b/lib/algora_web/live/onboarding/org.ex index 89d189921..c22764cc0 100644 --- a/lib/algora_web/live/onboarding/org.ex +++ b/lib/algora_web/live/onboarding/org.ex @@ -7,6 +7,7 @@ defmodule AlgoraWeb.Onboarding.OrgLive do alias Algora.Accounts alias Algora.Accounts.User + alias Algora.Organizations alias AlgoraWeb.Components.Wordmarks alias Phoenix.LiveView.AsyncResult alias Swoosh.Email From 888c50257dec0874401386298284b49c87b3d3f5 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 11:22:21 +0200 Subject: [PATCH 20/63] refactor: filter completed achievements in dashboard - Updated the assignment of achievements in the dashboard live view to exclude those with a status of :completed, improving the relevance of displayed data. --- lib/algora_web/live/org/dashboard_live.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 848204942..d8ddc8de7 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -680,7 +680,7 @@ defmodule AlgoraWeb.Org.DashboardLive do {:cont, result} end) - assign(socket, :achievements, achievements) + assign(socket, :achievements, Enum.reject(achievements, &(&1.status == :completed))) end defp personalize_status(_socket), do: :completed From 2e2c89a6b701a71182029871c7f35cc4f226e5e8 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 11:46:40 +0200 Subject: [PATCH 21/63] refactor: improve dashboard live view for bounties - Changed the visibility of the `assign_more_bounties` function to private, enhancing encapsulation. - Updated the dashboard live view to include a new layout for displaying bounties, improving UI consistency and user experience. - Implemented tab navigation for open and completed bounties, allowing users to filter displayed bounties more effectively. - Enhanced the handling of transactions and bounties, ensuring better data management and presentation. --- lib/algora_web/live/org/bounties_live.ex | 2 +- lib/algora_web/live/org/dashboard_live.ex | 288 ++++++++++++++++++---- 2 files changed, 238 insertions(+), 52 deletions(-) diff --git a/lib/algora_web/live/org/bounties_live.ex b/lib/algora_web/live/org/bounties_live.ex index f9ed4345c..0301c07d7 100644 --- a/lib/algora_web/live/org/bounties_live.ex +++ b/lib/algora_web/live/org/bounties_live.ex @@ -324,7 +324,7 @@ defmodule AlgoraWeb.Org.BountiesLive do defp to_transaction_rows(transactions), do: transactions - def assign_more_bounties(socket) do + defp assign_more_bounties(socket) do %{rows: rows, current_org: current_org} = socket.assigns last_bounty = List.last(rows).bounty diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index d8ddc8de7..4770b4ee5 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -3,6 +3,7 @@ defmodule AlgoraWeb.Org.DashboardLive do use AlgoraWeb, :live_view import AlgoraWeb.Components.Achievement + import AlgoraWeb.Components.Bounties import Ecto.Changeset import Ecto.Query @@ -14,6 +15,7 @@ defmodule AlgoraWeb.Org.DashboardLive do alias Algora.Contracts alias Algora.Github alias Algora.Organizations + alias Algora.Payments alias Algora.Payments.Transaction alias Algora.Repo alias Algora.Workspace @@ -32,7 +34,7 @@ defmodule AlgoraWeb.Org.DashboardLive do %{current_org: current_org} = socket.assigns if socket.assigns.current_user_role in [:admin, :mod] do - top_earners = Accounts.list_developers(org_id: current_org.id, earnings_gt: Money.zero(:USD)) + _top_earners = Accounts.list_developers(org_id: current_org.id, earnings_gt: Money.zero(:USD)) installations = Workspace.list_installations_by(connected_user_id: current_org.id, provider: "github") @@ -58,7 +60,8 @@ defmodule AlgoraWeb.Org.DashboardLive do socket |> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user)) |> assign(:installations, installations) - |> assign(:matching_devs, top_earners) + # |> assign(:matching_devs, top_earners) + |> assign(:matching_devs, []) |> assign(:contributors, contributors) |> assign(:bounties, bounties) |> assign(:has_more_bounties, false) @@ -224,63 +227,129 @@ defmodule AlgoraWeb.Org.DashboardLive do
    - <.section - title={"#{@current_org.name} Bounties"} - subtitle={"List of bounties posted by #{@current_org.name}"} - > -
    -
    -
      - <%= for bounty <- @bounties do %> - <.link href={bounty.ticket.url} class="block whitespace-nowrap hover:bg-muted/50"> -
    • -
      - <.avatar class="h-8 w-8"> - <.avatar_image src={bounty.repository.owner.avatar_url} /> - <.avatar_fallback> - {Algora.Util.initials(User.handle(bounty.repository.owner))} - - +
      +
      +
      +
      +

      Bounties

      +

      + Create new bounties using the + + /bounty $1000 + + command on Github. +

      +
      +
      + +
      +
      + + +
      +
      +
      +
      +
      +
      +
      + <.bounties bounties={@bounties} /> +
      +
      + <.icon name="tabler-loader" class="h-6 w-6 animate-spin" /> +
      +
      +
      +
      +
      + <%= for %{transaction: transaction, recipient: recipient, ticket: ticket} <- @transaction_rows do %> +
      +
      +
      +
      + {Money.to_string!(transaction.net_amount)} +
      +
      + {ticket.repository.user.provider_login}/{ticket.repository.name}#{ticket.number} +
      +
      + {ticket.title} +
      +
      + {Algora.Util.time_ago(transaction.succeeded_at)} +
      +
      -
      - <.button variant="secondary"> - Skip - - <.button> - Confirm - +
      +

      + Awarded to +

      + {recipient.name} +
      + {recipient.name} +
      + {Algora.Misc.CountryEmojis.get(recipient.country)}
      -
    • - - <% end %> -
    -
    -
    -
    +
    +
    +
    +
    + <% end %> +
    +
    <.icon name="tabler-loader" class="h-6 w-6 animate-spin" />
    - + <.section>
    @@ -382,6 +451,7 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + @impl true def handle_event("create_tip" = event, %{"tip_form" => params} = unsigned_params, socket) do if socket.assigns.has_fresh_token? do changeset = @@ -416,6 +486,7 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + @impl true def handle_event("offer_contract", %{"user_id" => user_id}, socket) do developer = Enum.find(socket.assigns.matching_devs, &(&1.id == user_id)) @@ -425,15 +496,18 @@ defmodule AlgoraWeb.Org.DashboardLive do |> assign(:show_contract_modal, true)} end + @impl true def handle_event("offer_contract", _params, socket) do # When no user_id is provided, use the user from the current row {:noreply, put_flash(socket, :error, "Please select a developer first")} end + @impl true def handle_event("close_contract_drawer", _params, socket) do {:noreply, assign(socket, :show_contract_modal, false)} end + @impl true def handle_event("validate_contract", %{"contract_form" => params}, socket) do changeset = %ContractForm{} @@ -443,6 +517,7 @@ defmodule AlgoraWeb.Org.DashboardLive do {:noreply, assign(socket, :contract_form, to_form(changeset))} end + @impl true def handle_event("create_contract", %{"contract_form" => params}, socket) do changeset = ContractForm.changeset(%ContractForm{}, params) @@ -474,6 +549,7 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + @impl true def handle_event("send_login_code", %{"user" => %{"email" => email}}, socket) do code = Nanoid.generate() @@ -493,6 +569,7 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + @impl true def handle_event("send_login_code", %{"user" => %{"login_code" => code}}, socket) do if Plug.Crypto.secure_compare(code, socket.assigns.secret_code) do handle = @@ -527,12 +604,121 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + @impl true + def handle_event("change-tab", %{"tab" => "completed"}, socket) do + {:noreply, push_patch(socket, to: ~p"/org/#{socket.assigns.current_org.handle}?status=completed")} + end + + @impl true + def handle_event("change-tab", %{"tab" => "open"}, socket) do + {:noreply, push_patch(socket, to: ~p"/org/#{socket.assigns.current_org.handle}?status=open")} + end + + @impl true + def handle_event("load_more", _params, socket) do + {:noreply, + case socket.assigns.current_status do + :open -> assign_more_bounties(socket) + :paid -> assign_more_transactions(socket) + end} + end + + @impl true + def handle_params(params, _uri, socket) do + current_org = socket.assigns.current_org + current_status = get_current_status(params) + + stats = Bounties.fetch_stats(current_org.id) + + bounties = Bounties.list_bounties(owner_id: current_org.id, limit: page_size(), status: :open) + transactions = Payments.list_sent_transactions(current_org.id, limit: page_size()) + + {:noreply, + socket + |> assign(:current_status, current_status) + |> assign(:bounty_rows, to_bounty_rows(bounties)) + |> assign(:transaction_rows, to_transaction_rows(transactions)) + |> assign(:has_more_bounties, length(bounties) >= page_size()) + |> assign(:has_more_transactions, length(transactions) >= page_size()) + |> assign(:stats, stats)} + end + defp throttle, do: :timer.sleep(1000) defp assign_login_form(socket, %Ecto.Changeset{} = changeset) do assign(socket, :login_form, to_form(changeset)) end + defp to_bounty_rows(bounties) do + claims_by_ticket = + bounties + |> Enum.map(& &1.ticket.id) + |> Bounties.list_claims() + |> Enum.group_by(& &1.target_id) + |> Map.new(fn {ticket_id, claims} -> + {ticket_id, Enum.group_by(claims, & &1.group_id)} + end) + + Enum.map(bounties, fn bounty -> + %{bounty: bounty, claim_groups: Map.get(claims_by_ticket, bounty.ticket.id, %{})} + end) + end + + defp to_transaction_rows(transactions), do: transactions + + defp assign_more_bounties(socket) do + %{rows: rows, current_org: current_org} = socket.assigns + + last_bounty = List.last(rows).bounty + + cursor = %{ + inserted_at: last_bounty.inserted_at, + id: last_bounty.id + } + + more_bounties = + Bounties.list_bounties( + owner_id: current_org.id, + limit: page_size(), + status: socket.assigns.current_status, + before: cursor + ) + + socket + |> assign(:bounty_rows, rows ++ to_bounty_rows(more_bounties)) + |> assign(:has_more, length(more_bounties) >= page_size()) + end + + defp assign_more_transactions(socket) do + %{transaction_rows: rows, current_org: current_org} = socket.assigns + + last_transaction = List.last(rows).transaction + + more_transactions = + Payments.list_sent_transactions( + current_org.id, + limit: page_size(), + before: %{ + succeeded_at: last_transaction.succeeded_at, + id: last_transaction.id + } + ) + + socket + |> assign(:transaction_rows, rows ++ to_transaction_rows(more_transactions)) + |> assign(:has_more_transactions, length(more_transactions) >= page_size()) + end + + defp get_current_status(params) do + case params["status"] do + "open" -> :open + "completed" -> :paid + _ -> :open + end + end + + defp page_size, do: 10 + @from_name "Algora" @from_email "info@algora.io" From dc28fec053cafdc2debc7bcd768f3a76b5b12545 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 11:53:04 +0200 Subject: [PATCH 22/63] refactor: enhance dashboard live view functionality - Reorganized the assignment of bounties and transactions in the dashboard live view for improved clarity and performance. - Updated the handling of parameters to include current status and statistics, enhancing the data presentation. - Simplified the `to_bounty_rows` function to directly return bounty data, streamlining the code. - Adjusted UI elements for better user engagement with contributors and bounties. --- lib/algora_web/live/org/dashboard_live.ex | 73 +++++++++-------------- 1 file changed, 27 insertions(+), 46 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 4770b4ee5..ea1804df1 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -34,15 +34,13 @@ defmodule AlgoraWeb.Org.DashboardLive do %{current_org: current_org} = socket.assigns if socket.assigns.current_user_role in [:admin, :mod] do - _top_earners = Accounts.list_developers(org_id: current_org.id, earnings_gt: Money.zero(:USD)) - - installations = Workspace.list_installations_by(connected_user_id: current_org.id, provider: "github") - if connected?(socket) do Phoenix.PubSub.subscribe(Algora.PubSub, "auth:#{socket.id}") end - bounties = Bounties.list_bounties(owner_id: current_org.id) + _top_earners = Accounts.list_developers(org_id: current_org.id, earnings_gt: Money.zero(:USD)) + + installations = Workspace.list_installations_by(connected_user_id: current_org.id, provider: "github") contributors = case current_org.last_context do @@ -63,7 +61,6 @@ defmodule AlgoraWeb.Org.DashboardLive do # |> assign(:matching_devs, top_earners) |> assign(:matching_devs, []) |> assign(:contributors, contributors) - |> assign(:bounties, bounties) |> assign(:has_more_bounties, false) |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) |> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) @@ -81,6 +78,26 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + @impl true + def handle_params(params, _uri, socket) do + current_org = socket.assigns.current_org + current_status = get_current_status(params) + + stats = Bounties.fetch_stats(current_org.id) + + bounties = Bounties.list_bounties(owner_id: current_org.id, limit: page_size(), status: :open) + transactions = Payments.list_sent_transactions(current_org.id, limit: page_size()) + + {:noreply, + socket + |> assign(:current_status, current_status) + |> assign(:bounty_rows, to_bounty_rows(bounties)) + |> assign(:transaction_rows, to_transaction_rows(transactions)) + |> assign(:has_more_bounties, length(bounties) >= page_size()) + |> assign(:has_more_transactions, length(transactions) >= page_size()) + |> assign(:stats, stats)} + end + @impl true def render(assigns) do ~H""" @@ -198,7 +215,7 @@ defmodule AlgoraWeb.Org.DashboardLive do <.section :if={@contributors != []} title={"#{@current_org.name} Contributors"} - subtitle="Engage your top contributors with tips or contract opportunities" + subtitle="Share bounties, tips or contract opportunities with your top contributors" >
    @@ -288,12 +305,9 @@ defmodule AlgoraWeb.Org.DashboardLive do -
    +
    - <.bounties bounties={@bounties} /> + <.bounties bounties={@bounty_rows} />
    <.icon name="tabler-loader" class="h-6 w-6 animate-spin" /> @@ -623,46 +637,13 @@ defmodule AlgoraWeb.Org.DashboardLive do end} end - @impl true - def handle_params(params, _uri, socket) do - current_org = socket.assigns.current_org - current_status = get_current_status(params) - - stats = Bounties.fetch_stats(current_org.id) - - bounties = Bounties.list_bounties(owner_id: current_org.id, limit: page_size(), status: :open) - transactions = Payments.list_sent_transactions(current_org.id, limit: page_size()) - - {:noreply, - socket - |> assign(:current_status, current_status) - |> assign(:bounty_rows, to_bounty_rows(bounties)) - |> assign(:transaction_rows, to_transaction_rows(transactions)) - |> assign(:has_more_bounties, length(bounties) >= page_size()) - |> assign(:has_more_transactions, length(transactions) >= page_size()) - |> assign(:stats, stats)} - end - defp throttle, do: :timer.sleep(1000) defp assign_login_form(socket, %Ecto.Changeset{} = changeset) do assign(socket, :login_form, to_form(changeset)) end - defp to_bounty_rows(bounties) do - claims_by_ticket = - bounties - |> Enum.map(& &1.ticket.id) - |> Bounties.list_claims() - |> Enum.group_by(& &1.target_id) - |> Map.new(fn {ticket_id, claims} -> - {ticket_id, Enum.group_by(claims, & &1.group_id)} - end) - - Enum.map(bounties, fn bounty -> - %{bounty: bounty, claim_groups: Map.get(claims_by_ticket, bounty.ticket.id, %{})} - end) - end + defp to_bounty_rows(bounties), do: bounties defp to_transaction_rows(transactions), do: transactions From 87e24aadd1e4ce30cbf6651b61a4edcf817188e4 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 11:59:28 +0200 Subject: [PATCH 23/63] refactor: adjust UI spacing and layout in live views - Reduced padding in section headers for a more compact layout across various live views. - Removed unnecessary margin adjustments to improve visual consistency in the display of tech badges and community elements. - Enhanced the dashboard live view by adding titles and subtitles to sections, improving context and user engagement. --- lib/algora_web/components/core_components.ex | 2 +- lib/algora_web/live/bounties_live.ex | 2 +- lib/algora_web/live/community_live.ex | 2 +- lib/algora_web/live/org/dashboard_live.ex | 11 +++++++---- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index 54452931c..2e3ba6c48 100644 --- a/lib/algora_web/components/core_components.ex +++ b/lib/algora_web/components/core_components.ex @@ -1167,7 +1167,7 @@ defmodule AlgoraWeb.CoreComponents do def section(assigns) do ~H"""
    -
    +

    {@title}

    {@subtitle}

    diff --git a/lib/algora_web/live/bounties_live.ex b/lib/algora_web/live/bounties_live.ex index fa0dc45c0..77b684d53 100644 --- a/lib/algora_web/live/bounties_live.ex +++ b/lib/algora_web/live/bounties_live.ex @@ -76,7 +76,7 @@ defmodule AlgoraWeb.BountiesLive do ~H"""
    <.section title="Bounties" subtitle="Open bounties for you"> -
    +
    <%= for {tech, count} <- @techs do %>
    <.badge diff --git a/lib/algora_web/live/community_live.ex b/lib/algora_web/live/community_live.ex index 2a741ef21..7d8681502 100644 --- a/lib/algora_web/live/community_live.ex +++ b/lib/algora_web/live/community_live.ex @@ -37,7 +37,7 @@ defmodule AlgoraWeb.CommunityLive do ~H"""
    <.section title="Community" subtitle="Meet the Algora community"> -
    +
    <%= for tech <- @techs do %>
    -
    +
    <%= for %Contributor{user: user} <- @contributors do %> @@ -233,7 +233,7 @@ defmodule AlgoraWeb.Org.DashboardLive do title="Algora Experts" subtitle="Meet Algora experts versed in your tech stack" > -
    +
    <%= for user <- @matching_devs do %> @@ -244,7 +244,7 @@ defmodule AlgoraWeb.Org.DashboardLive do -
    +
    @@ -365,7 +365,10 @@ defmodule AlgoraWeb.Org.DashboardLive do
    - <.section> + <.section + title={"#{@current_org.name} Ecosystem"} + subtitle="Help maintain and grow your ecosystem by creating bounties and tips in your dependencies" + >
    {create_bounty(assigns)} {create_tip(assigns)} From 9e38bfd1a69d0d4498bc8b226bdfb0fa02a6b162 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 12:11:19 +0200 Subject: [PATCH 24/63] refactor: update button variants and enhance dashboard interactions - Added a new "none" button variant for improved styling flexibility. - Refactored button components in the dashboard live view to utilize the new variant, enhancing the user interface. - Streamlined button interactions for offering bounties and tips, improving user engagement and clarity in actions. --- lib/algora_web/components/ui/button.ex | 3 +- lib/algora_web/live/org/dashboard_live.ex | 93 ++++++++++++----------- 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/lib/algora_web/components/ui/button.ex b/lib/algora_web/components/ui/button.ex index a985dd7af..1617a38be 100644 --- a/lib/algora_web/components/ui/button.ex +++ b/lib/algora_web/components/ui/button.ex @@ -74,7 +74,8 @@ defmodule AlgoraWeb.Components.UI.Button do "secondary" => "bg-secondary hover:bg-secondary/80 text-foreground border-secondary-foreground/20 hover:border-secondary-foreground/40 focus-visible:outline-secondary-foreground shadow border", "ghost" => "hover:bg-accent hover:text-accent-foreground", - "link" => "text-primary underline-offset-4 hover:underline" + "link" => "text-primary underline-offset-4 hover:underline", + "none" => "" }, size: %{ "default" => "h-9 px-4 py-2 text-sm", diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index f91fd3427..e4c16a384 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -957,57 +957,60 @@ defmodule AlgoraWeb.Org.DashboardLive do
    - <%= if contract_for_user(@contracts, @user) do %> +
    <.button - variant="secondary" + phx-click="offer_bounty" + phx-value-user_id={@user.id} + variant="none" + class="group flex items-center justify-center bg-card text-foreground transition-colors duration-75 hover:bg-emerald-500/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-500/10 focus:text-emerald-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50" + > + Bounty + + <.button + phx-click="offer_tip" + phx-value-user_id={@user.id} + variant="none" + class="group flex items-center justify-center bg-card text-foreground transition-colors duration-75 hover:bg-red-500/10 hover:text-red-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-red-500/10 focus:text-red-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-red-400/50" + > + Tip + + + <.button + :if={contract_for_user(@contracts, @user)} navigate={ ~p"/org/#{@current_org.handle}/contracts/#{contract_for_user(@contracts, @user).id}" } + variant="none" + class="group flex items-center justify-center bg-card text-foreground transition-colors duration-75 hover:bg-blue-500/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-500/10 focus:text-blue-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50" > - View contract + Contract - <% else %> -
    - <.button phx-click="offer_contract" phx-value-user_id={@user.id} variant="secondary"> - Tip - - <.button phx-click="offer_contract" phx-value-user_id={@user.id}> - Contract - - <.dropdown_menu> - <.dropdown_menu_trigger> - <.button variant="ghost" size="icon"> - - - - - - Open menu - - - <.dropdown_menu_content> - <.dropdown_menu_item> - View Profile - - <.dropdown_menu_separator /> - <.dropdown_menu_item phx-click="remove"> - Remove - - - -
    - <% end %> + <.button + :if={!contract_for_user(@contracts, @user)} + phx-click="offer_contract" + phx-value-user_id={@user.id} + variant="none" + class="group flex items-center justify-center bg-card text-foreground transition-colors duration-75 hover:bg-blue-500/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-500/10 focus:text-blue-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50" + > + Contract + + <.dropdown_menu> + <.dropdown_menu_trigger> + <.button variant="ghost" size="icon"> + <.icon name="tabler-dots" class="h-4 w-4" /> + + + <.dropdown_menu_content> + <.dropdown_menu_item> + View Profile + + <.dropdown_menu_separator /> + <.dropdown_menu_item phx-click="remove"> + Remove + + + +
    From 7eb46811fde914ea2abee94cf80f79a404947712 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 12:26:31 +0200 Subject: [PATCH 25/63] refactor: update dashboard live view for sharing opportunities - Renamed variables and functions for clarity, changing `top_earners` to `experts` and `matching_dev` to `developer_card`. - Introduced a share drawer for sharing bounties, tips, and contracts, enhancing user interaction. - Updated event handling to support sharing opportunities, improving the overall functionality of the dashboard. - Adjusted UI elements to reflect the new sharing features, ensuring a cohesive user experience. --- lib/algora_web/live/org/dashboard_live.ex | 311 +++++++++++++--------- 1 file changed, 182 insertions(+), 129 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index e4c16a384..62da062fa 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -38,7 +38,8 @@ defmodule AlgoraWeb.Org.DashboardLive do Phoenix.PubSub.subscribe(Algora.PubSub, "auth:#{socket.id}") end - _top_earners = Accounts.list_developers(org_id: current_org.id, earnings_gt: Money.zero(:USD)) + _experts = Accounts.list_developers(org_id: current_org.id, earnings_gt: Money.zero(:USD)) + experts = [] installations = Workspace.list_installations_by(connected_user_id: current_org.id, provider: "github") @@ -58,15 +59,16 @@ defmodule AlgoraWeb.Org.DashboardLive do socket |> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user)) |> assign(:installations, installations) - # |> assign(:matching_devs, top_earners) - |> assign(:matching_devs, []) + |> assign(:experts, experts) |> assign(:contributors, contributors) + |> assign(:developers, contributors |> Enum.map(& &1.user) |> Enum.concat(experts)) |> assign(:has_more_bounties, false) |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) |> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) |> assign(:tip_form, to_form(TipForm.changeset(%TipForm{}, %{}))) |> assign(:contract_form, to_form(ContractForm.changeset(%ContractForm{}, %{}))) - |> assign(:show_contract_modal, false) + |> assign(:show_share_drawer, false) + |> assign(:share_drawer_type, nil) |> assign(:selected_developer, nil) |> assign(:secret_code, nil) |> assign_login_form(User.login_changeset(%User{}, %{})) @@ -221,7 +223,7 @@ defmodule AlgoraWeb.Org.DashboardLive do
    <%= for %Contributor{user: user} <- @contributors do %> - <.matching_dev user={user} contracts={@contracts} current_org={@current_org} /> + <.developer_card user={user} contracts={@contracts} current_org={@current_org} /> <% end %>
    @@ -229,15 +231,15 @@ defmodule AlgoraWeb.Org.DashboardLive do <.section - :if={@matching_devs != []} + :if={@experts != []} title="Algora Experts" subtitle="Meet Algora experts versed in your tech stack" >
    - <%= for user <- @matching_devs do %> - <.matching_dev user={user} contracts={@contracts} current_org={@current_org} /> + <%= for user <- @experts do %> + <.developer_card user={user} contracts={@contracts} current_org={@current_org} /> <% end %>
    @@ -395,7 +397,7 @@ defmodule AlgoraWeb.Org.DashboardLive do
    {sidebar(assigns)} - {contract_modal(assigns)} + {share_drawer(assigns)} """ end @@ -504,24 +506,24 @@ defmodule AlgoraWeb.Org.DashboardLive do end @impl true - def handle_event("offer_contract", %{"user_id" => user_id}, socket) do - developer = Enum.find(socket.assigns.matching_devs, &(&1.id == user_id)) + def handle_event("share_opportunity", %{"user_id" => user_id, "type" => type}, socket) do + developer = Enum.find(socket.assigns.developers, &(&1.id == user_id)) {:noreply, socket |> assign(:selected_developer, developer) - |> assign(:show_contract_modal, true)} + |> assign(:share_drawer_type, type) + |> assign(:show_share_drawer, true)} end @impl true - def handle_event("offer_contract", _params, socket) do - # When no user_id is provided, use the user from the current row + def handle_event("share_opportunity", _params, socket) do {:noreply, put_flash(socket, :error, "Please select a developer first")} end @impl true - def handle_event("close_contract_drawer", _params, socket) do - {:noreply, assign(socket, :show_contract_modal, false)} + def handle_event("close_share_drawer", _params, socket) do + {:noreply, assign(socket, :show_share_drawer, false)} end @impl true @@ -553,7 +555,7 @@ defmodule AlgoraWeb.Org.DashboardLive do # TODO: send email {:noreply, socket - |> assign(:show_contract_modal, false) + |> assign(:show_share_drawer, false) |> assign_contracts() |> put_flash(:info, "Contract offer sent to #{socket.assigns.selected_developer.name}")} @@ -892,7 +894,7 @@ defmodule AlgoraWeb.Org.DashboardLive do defp share_with_friend_status(_socket), do: :upcoming - defp matching_dev(assigns) do + defp developer_card(assigns) do ~H""" @@ -959,16 +961,18 @@ defmodule AlgoraWeb.Org.DashboardLive do
    <.button - phx-click="offer_bounty" + phx-click="share_opportunity" phx-value-user_id={@user.id} + phx-value-type="bounty" variant="none" class="group flex items-center justify-center bg-card text-foreground transition-colors duration-75 hover:bg-emerald-500/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-500/10 focus:text-emerald-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50" > Bounty <.button - phx-click="offer_tip" + phx-click="share_opportunity" phx-value-user_id={@user.id} + phx-value-type="tip" variant="none" class="group flex items-center justify-center bg-card text-foreground transition-colors duration-75 hover:bg-red-500/10 hover:text-red-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-red-500/10 focus:text-red-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-red-400/50" > @@ -987,8 +991,9 @@ defmodule AlgoraWeb.Org.DashboardLive do <.button :if={!contract_for_user(@contracts, @user)} - phx-click="offer_contract" + phx-click="share_opportunity" phx-value-user_id={@user.id} + phx-value-type="contract" variant="none" class="group flex items-center justify-center bg-card text-foreground transition-colors duration-75 hover:bg-blue-500/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-500/10 focus:text-blue-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50" > @@ -1124,120 +1129,168 @@ defmodule AlgoraWeb.Org.DashboardLive do """ end - defp contract_modal(assigns) do + defp share_drawer_header(%{share_drawer_type: "contract"} = assigns) do ~H""" - <.drawer show={@show_contract_modal} direction="right" on_cancel="close_contract_drawer"> - <.drawer_header :if={@selected_developer}> - <.drawer_title>Offer Contract - <.drawer_description> - Once you send an offer, {@selected_developer.name} will be notified and can accept or decline. - - - <.drawer_content :if={@selected_developer} class="mt-4"> - <.form for={@contract_form} phx-change="validate_contract" phx-submit="create_contract"> -
    - <.card> - <.card_header> - <.card_title>Developer - - <.card_content> -
    - <.avatar class="h-20 w-20 rounded-full"> - <.avatar_image - src={@selected_developer.avatar_url} - alt={@selected_developer.name} - /> - <.avatar_fallback class="rounded-lg"> - {Algora.Util.initials(@selected_developer.name)} - - - -
    -
    - {@selected_developer.name} -
    + <.drawer_header> + <.drawer_title>Offer Contract + <.drawer_description> + Once you send an offer, {@selected_developer.name} will be notified and can accept or decline. + + + """ + end -
    - <.link - :if={@selected_developer.provider_login} - href={"https://github.com/#{@selected_developer.provider_login}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - - {@selected_developer.provider_login} - - <.link - :if={@selected_developer.provider_meta["twitter_handle"]} - href={"https://x.com/#{@selected_developer.provider_meta["twitter_handle"]}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - <.icon name="tabler-brand-x" class="h-4 w-4" /> - - {@selected_developer.provider_meta["twitter_handle"]} - - -
    - <.icon name="tabler-map-pin" class="h-4 w-4" /> - - {@selected_developer.provider_meta["location"]} - -
    -
    - <.icon name="tabler-building" class="h-4 w-4" /> - - {@selected_developer.provider_meta["company"] |> String.trim_leading("@")} - -
    -
    + defp share_drawer_header(%{share_drawer_type: "bounty"} = assigns) do + ~H""" + <.drawer_header> + <.drawer_title>Share Bounty + <.drawer_description> + Share a bounty opportunity with {@selected_developer.name}. They will be notified and can choose to work on it. + + + """ + end -
    - <%= for tech <- @selected_developer.tech_stack do %> -
    - {tech} -
    - <% end %> -
    -
    -
    - - - - <.card> - <.card_header> - <.card_title>Contract Details - - <.card_content> -
    - <.input - label="Hourly Rate" - icon="tabler-currency-dollar" - field={@contract_form[:hourly_rate]} - /> - <.input label="Hours per Week" field={@contract_form[:hours_per_week]} /> + defp share_drawer_header(%{share_drawer_type: "tip"} = assigns) do + ~H""" + <.drawer_header> + <.drawer_title>Send Tip + <.drawer_description> + Send a tip to {@selected_developer.name} to show appreciation for their contributions. + + + """ + end + + defp share_drawer_content(%{share_drawer_type: "contract"} = assigns) do + ~H""" + <.card> + <.card_header> + <.card_title>Contract Details + + <.card_content> +
    + <.input + label="Hourly Rate" + icon="tabler-currency-dollar" + field={@contract_form[:hourly_rate]} + /> + <.input label="Hours per Week" field={@contract_form[:hours_per_week]} /> +
    + + + +
    + <.button variant="secondary" phx-click="close_share_drawer" type="button"> + Cancel + + <.button type="submit"> + Send Contract Offer <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
    + """ + end + + defp share_drawer_content(%{share_drawer_type: "bounty"} = assigns) do + ~H""" + """ + end + + defp share_drawer_content(%{share_drawer_type: "tip"} = assigns) do + ~H""" + """ + end + + defp share_drawer_developer_info(assigns) do + ~H""" + <.card> + <.card_header> + <.card_title>Developer + + <.card_content> +
    + <.avatar class="h-20 w-20 rounded-full"> + <.avatar_image src={@selected_developer.avatar_url} alt={@selected_developer.name} /> + <.avatar_fallback class="rounded-lg"> + {Algora.Util.initials(@selected_developer.name)} + + + +
    +
    + {@selected_developer.name} +
    + +
    + <.link + :if={@selected_developer.provider_login} + href={"https://github.com/#{@selected_developer.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + + {@selected_developer.provider_login} + + <.link + :if={@selected_developer.provider_meta["twitter_handle"]} + href={"https://x.com/#{@selected_developer.provider_meta["twitter_handle"]}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="tabler-brand-x" class="h-4 w-4" /> + + {@selected_developer.provider_meta["twitter_handle"]} + + +
    + <.icon name="tabler-map-pin" class="h-4 w-4" /> + + {@selected_developer.provider_meta["location"]} + +
    +
    + <.icon name="tabler-building" class="h-4 w-4" /> + + {@selected_developer.provider_meta["company"] |> String.trim_leading("@")} + +
    +
    + +
    + <%= for tech <- @selected_developer.tech_stack do %> +
    + {tech}
    - - - -
    - <.button variant="secondary" phx-click="close_contract_drawer" type="button"> - Cancel - - <.button type="submit"> - Send Contract Offer <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> - + <% end %>
    +
    + + + """ + end + + defp share_drawer(assigns) do + ~H""" + <.drawer show={@show_share_drawer} direction="right" on_cancel="close_share_drawer"> + <.share_drawer_header + :if={@selected_developer} + selected_developer={@selected_developer} + share_drawer_type={@share_drawer_type} + /> + <.drawer_content :if={@selected_developer} class="mt-4"> + <.form for={@contract_form} phx-change="validate_contract" phx-submit="create_contract"> +
    + <.share_drawer_developer_info selected_developer={@selected_developer} /> + <.share_drawer_content + :if={@selected_developer} + share_drawer_type={@share_drawer_type} + contract_form={@contract_form} + /> +
    From 37e0afb19b1f7b687bb98e45c2851bb40163ffe3 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 12:31:51 +0200 Subject: [PATCH 26/63] refactor: update button styles and icons in dashboard live view - Enhanced button styles for bounties, tips, and contracts to improve visual feedback with updated hover and focus states. - Added icons to buttons for bounties, tips, and contracts, enhancing user experience and clarity of actions. - Streamlined button classes for consistency across the dashboard, ensuring a cohesive design. --- lib/algora_web/live/org/dashboard_live.ex | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 62da062fa..d1619a391 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -965,18 +965,18 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@user.id} phx-value-type="bounty" variant="none" - class="group flex items-center justify-center bg-card text-foreground transition-colors duration-75 hover:bg-emerald-500/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-500/10 focus:text-emerald-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-600/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-600/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" > - Bounty + <.icon name="tabler-diamond" class="size-5 text-current mr-2 -ml-1" /> Bounty <.button phx-click="share_opportunity" phx-value-user_id={@user.id} phx-value-type="tip" variant="none" - class="group flex items-center justify-center bg-card text-foreground transition-colors duration-75 hover:bg-red-500/10 hover:text-red-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-red-500/10 focus:text-red-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-red-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-red-600/10 hover:text-red-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-red-600/10 focus:text-red-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-red-400/50 focus:border-red-400/50" > - Tip + <.icon name="tabler-heart" class="size-5 text-current mr-2 -ml-1" /> Tip <.button @@ -985,9 +985,9 @@ defmodule AlgoraWeb.Org.DashboardLive do ~p"/org/#{@current_org.handle}/contracts/#{contract_for_user(@contracts, @user).id}" } variant="none" - class="group flex items-center justify-center bg-card text-foreground transition-colors duration-75 hover:bg-blue-500/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-500/10 focus:text-blue-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-600/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-600/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" > - Contract + <.icon name="tabler-contract" class="size-5 text-current mr-2 -ml-1" /> Contract <.button :if={!contract_for_user(@contracts, @user)} @@ -995,9 +995,9 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@user.id} phx-value-type="contract" variant="none" - class="group flex items-center justify-center bg-card text-foreground transition-colors duration-75 hover:bg-blue-500/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-500/10 focus:text-blue-400 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-600/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-600/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" > - Contract + <.icon name="tabler-contract" class="size-5 text-current mr-2 -ml-1" /> Contract <.dropdown_menu> <.dropdown_menu_trigger> From 08a5da2da6ac184a40cf0063ba061241e5f64070 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 12:38:03 +0200 Subject: [PATCH 27/63] refactor: enhance share drawer functionality in dashboard live view - Added detailed forms for sharing bounties and tips, improving user interaction and clarity. - Updated button styles and icons for consistency across the share drawer. - Enhanced the layout of the share drawer content to provide a better user experience when sharing opportunities. --- lib/algora_web/live/org/dashboard_live.ex | 72 ++++++++++++++++++++--- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index d1619a391..a3b397374 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -965,18 +965,18 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@user.id} phx-value-type="bounty" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-600/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-600/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-600/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-600/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" > - <.icon name="tabler-diamond" class="size-5 text-current mr-2 -ml-1" /> Bounty + <.icon name="tabler-diamond" class="size-4 text-current mr-2 -ml-1" /> Bounty <.button phx-click="share_opportunity" phx-value-user_id={@user.id} phx-value-type="tip" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-red-600/10 hover:text-red-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-red-600/10 focus:text-red-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-red-400/50 focus:border-red-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-rose-600/10 hover:text-rose-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-rose-600/10 focus:text-rose-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-rose-400/50 focus:border-rose-400/50" > - <.icon name="tabler-heart" class="size-5 text-current mr-2 -ml-1" /> Tip + <.icon name="tabler-heart" class="size-4 text-current mr-2 -ml-1" /> Tip <.button @@ -985,9 +985,9 @@ defmodule AlgoraWeb.Org.DashboardLive do ~p"/org/#{@current_org.handle}/contracts/#{contract_for_user(@contracts, @user).id}" } variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-600/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-600/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-600/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-600/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" > - <.icon name="tabler-contract" class="size-5 text-current mr-2 -ml-1" /> Contract + <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract <.button :if={!contract_for_user(@contracts, @user)} @@ -995,9 +995,9 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@user.id} phx-value-type="contract" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-600/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-600/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-600/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-600/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" > - <.icon name="tabler-contract" class="size-5 text-current mr-2 -ml-1" /> Contract + <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract <.dropdown_menu> <.dropdown_menu_trigger> @@ -1193,11 +1193,65 @@ defmodule AlgoraWeb.Org.DashboardLive do defp share_drawer_content(%{share_drawer_type: "bounty"} = assigns) do ~H""" + <.card> + <.card_header> + <.card_title>Bounty Details + + <.card_content> +
    + <.input + label="URL" + field={@bounty_form[:url]} + placeholder="https://github.com/owner/repo/issues/123" + /> + <.input label="Amount" icon="tabler-currency-dollar" field={@bounty_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 defp share_drawer_content(%{share_drawer_type: "tip"} = assigns) do ~H""" + <.card> + <.card_header> + <.card_title>Tip Details + + <.card_content> +
    + <.input label="Amount" icon="tabler-currency-dollar" field={@tip_form[:amount]} /> + <.input + label="URL" + field={@bounty_form[:url]} + placeholder="https://github.com/owner/repo/issues/123" + helptext="We'll add a comment to the issue with your tip to notify the developer." + /> + <.input + label="Message (optional)" + field={@tip_form[:message]} + placeholder="Thanks for your great work!" + /> +
    + + + +
    + <.button variant="secondary" phx-click="close_share_drawer" type="button"> + Cancel + + <.button type="submit"> + Send Tip <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
    """ end @@ -1289,6 +1343,8 @@ defmodule AlgoraWeb.Org.DashboardLive do :if={@selected_developer} share_drawer_type={@share_drawer_type} contract_form={@contract_form} + tip_form={@tip_form} + bounty_form={@bounty_form} />
    From 1c8e9786d4459eb643a04e0a10d66076bdd689b2 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 12:41:40 +0200 Subject: [PATCH 28/63] add url to tip form --- lib/algora_web/components/ui/drawer.ex | 2 +- lib/algora_web/forms/tip_form.ex | 13 +++++++++++-- lib/algora_web/live/home_live.ex | 1 + lib/algora_web/live/org/dashboard_live.ex | 4 ++-- lib/algora_web/live/swift_bounties_live.ex | 1 + 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/algora_web/components/ui/drawer.ex b/lib/algora_web/components/ui/drawer.ex index 204608ade..6dfc5cf9e 100644 --- a/lib/algora_web/components/ui/drawer.ex +++ b/lib/algora_web/components/ui/drawer.ex @@ -114,7 +114,7 @@ defmodule AlgoraWeb.Components.UI.Drawer do def drawer_content(assigns) do ~H""" -
    +
    {render_slot(@inner_block)}
    """ diff --git a/lib/algora_web/forms/tip_form.ex b/lib/algora_web/forms/tip_form.ex index 97d0105e9..bb3cbab9e 100644 --- a/lib/algora_web/forms/tip_form.ex +++ b/lib/algora_web/forms/tip_form.ex @@ -8,14 +8,23 @@ defmodule AlgoraWeb.Forms.TipForm do alias Algora.Validations embedded_schema do + field :url, :string field :github_handle, :string field :amount, USD + + embeds_one :ticket_ref, TicketRef, primary_key: false do + field :owner, :string + field :repo, :string + field :number, :integer + field :type, :string + end end def changeset(form, attrs \\ %{}) do form - |> cast(attrs, [:github_handle, :amount]) - |> validate_required([:github_handle, :amount]) + |> cast(attrs, [:url, :github_handle, :amount]) + |> validate_required([:url, :github_handle, :amount]) |> Validations.validate_money_positive(:amount) + |> Validations.validate_ticket_ref(:url, :ticket_ref) end end diff --git a/lib/algora_web/live/home_live.ex b/lib/algora_web/live/home_live.ex index f4854ad3b..c3f698e36 100644 --- a/lib/algora_web/live/home_live.ex +++ b/lib/algora_web/live/home_live.ex @@ -44,6 +44,7 @@ defmodule AlgoraWeb.HomeLive do |> assign(:stats, stats) |> assign(:faq_items, get_faq_items()) |> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) + # TODO: add url |> assign(:tip_form, to_form(TipForm.changeset(%TipForm{}, %{}))) |> assign(:pending_action, nil)} end diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index a3b397374..37adaf38b 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1231,9 +1231,9 @@ defmodule AlgoraWeb.Org.DashboardLive do <.input label="Amount" icon="tabler-currency-dollar" field={@tip_form[:amount]} /> <.input label="URL" - field={@bounty_form[:url]} + field={@tip_form[:url]} placeholder="https://github.com/owner/repo/issues/123" - helptext="We'll add a comment to the issue with your tip to notify the developer." + helptext="We'll add a comment to the issue to notify the developer." /> <.input label="Message (optional)" diff --git a/lib/algora_web/live/swift_bounties_live.ex b/lib/algora_web/live/swift_bounties_live.ex index 5121d7ab9..172a2a5a8 100644 --- a/lib/algora_web/live/swift_bounties_live.ex +++ b/lib/algora_web/live/swift_bounties_live.ex @@ -590,6 +590,7 @@ defmodule AlgoraWeb.SwiftBountiesLive do end def handle_event("create_tip" = event, %{"tip_form" => params} = unsigned_params, socket) do + # TODO: add url changeset = %TipForm{} |> TipForm.changeset(params) From 525a0be735db5f3c11f80544d23570e922c02632 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 12:55:48 +0200 Subject: [PATCH 29/63] refactor: update dashboard live view layout and functionality - Adjusted the maximum height of contributor and expert sections for better visual consistency. - Enhanced the display logic for bounties and transactions, introducing a card layout for empty states to improve user engagement. - Updated button styles for tipping to provide clearer visual feedback. - Renamed the message field in the tip form to "Review" for improved clarity. --- lib/algora_web/live/org/dashboard_live.ex | 157 ++++++++++++++-------- 1 file changed, 104 insertions(+), 53 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 37adaf38b..680fabdbb 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -219,7 +219,7 @@ defmodule AlgoraWeb.Org.DashboardLive do title={"#{@current_org.name} Contributors"} subtitle="Share bounties, tips or contract opportunities with your top contributors" > -
    +
    <%= for %Contributor{user: user} <- @contributors do %> @@ -235,7 +235,7 @@ defmodule AlgoraWeb.Org.DashboardLive do title="Algora Experts" subtitle="Meet Algora experts versed in your tech stack" > -
    +
    <%= for user <- @experts do %> @@ -259,7 +259,7 @@ defmodule AlgoraWeb.Org.DashboardLive do command on Github.

    -
    +
    0} class="pb-4 md:pb-0">
    -
    - <.bounties bounties={@bounty_rows} /> -
    -
    - <.icon name="tabler-loader" class="h-6 w-6 animate-spin" /> + <%= if Enum.empty?(@bounty_rows) do %> + <.card class="rounded-lg bg-card py-12 text-center lg:rounded-[2rem]"> + <.card_header> +
    + <.icon name="tabler-diamond" class="h-8 w-8 text-muted-foreground" /> +
    + <.card_title>No bounties yet + <.card_description class="pt-2"> + <%= if @installations == [] do %> + Install Algora in {@current_org.name} to create new bounties using the + + /bounty $1000 + + command on Github + <.button + :if={@installations == []} + phx-click="install_app" + class="mt-4 flex mx-auto" + > + Install Algora + + <% else %> + Create new bounties using the + + /bounty $1000 + + command on Github + <% end %> + + + + <% else %> +
    + <.bounties bounties={@bounty_rows} /> +
    +
    + <.icon name="tabler-loader" class="h-6 w-6 animate-spin" /> +
    -
    + <% end %>
    - <%= for %{transaction: transaction, recipient: recipient, ticket: ticket} <- @transaction_rows do %> -
    -
    -
    -
    - {Money.to_string!(transaction.net_amount)} -
    -
    - {ticket.repository.user.provider_login}/{ticket.repository.name}#{ticket.number} -
    -
    - {ticket.title} -
    -
    - {Algora.Util.time_ago(transaction.succeeded_at)} -
    + <%= if Enum.empty?(@bounty_rows) do %> + <.card class="rounded-lg bg-card py-12 text-center lg:rounded-[2rem]"> + <.card_header> +
    + <.icon name="tabler-diamond" class="h-8 w-8 text-muted-foreground" />
    + <.card_title>No completed bounties yet + <.card_description> + Completed bounties will appear here once completed + + + + <% else %> + <%= for %{transaction: transaction, recipient: recipient, ticket: ticket} <- @transaction_rows do %> +
    +
    +
    +
    + {Money.to_string!(transaction.net_amount)} +
    +
    + {ticket.repository.user.provider_login}/{ticket.repository.name}#{ticket.number} +
    +
    + {ticket.title} +
    +
    + {Algora.Util.time_ago(transaction.succeeded_at)} +
    +
    -
    -

    - Awarded to -

    - {recipient.name} -
    - {recipient.name} -
    - {Algora.Misc.CountryEmojis.get(recipient.country)} +
    +

    + Awarded to +

    + {recipient.name} +
    + {recipient.name} +
    + {Algora.Misc.CountryEmojis.get(recipient.country)} +
    + <% end %> +
    +
    + <.icon name="tabler-loader" class="h-6 w-6 animate-spin" /> +
    <% end %> -
    -
    - <.icon name="tabler-loader" class="h-6 w-6 animate-spin" /> -
    -
    @@ -899,9 +950,9 @@ defmodule AlgoraWeb.Org.DashboardLive do
    -
    +
    <.link navigate={User.url(@user)}> - <.avatar class="h-20 w-20 rounded-full"> + <.avatar class="h-12 w-12 rounded-full"> <.avatar_image src={@user.avatar_url} alt={@user.name} /> <.avatar_fallback class="rounded-lg"> {Algora.Util.initials(@user.name)} @@ -950,13 +1001,13 @@ defmodule AlgoraWeb.Org.DashboardLive do
    -
    + <%!--
    <%= for tech <- @user.tech_stack do %>
    {tech}
    <% end %> -
    +
    --%>
    @@ -974,7 +1025,7 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@user.id} phx-value-type="tip" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-rose-600/10 hover:text-rose-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-rose-600/10 focus:text-rose-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-rose-400/50 focus:border-rose-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-red-600/10 hover:text-red-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-red-600/10 focus:text-red-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-red-400/50 focus:border-red-400/50" > <.icon name="tabler-heart" class="size-4 text-current mr-2 -ml-1" /> Tip @@ -1236,7 +1287,7 @@ defmodule AlgoraWeb.Org.DashboardLive do helptext="We'll add a comment to the issue to notify the developer." /> <.input - label="Message (optional)" + label="Review (optional)" field={@tip_form[:message]} placeholder="Thanks for your great work!" /> From 7007637161bc53a8ed5a210987c9c1009341c556 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 12:56:10 +0200 Subject: [PATCH 30/63] refactor: remove GitHub integration section from dashboard live view - Eliminated the GitHub integration card from the dashboard live view to streamline the layout. - Adjusted the display logic to enhance overall user experience and focus on core functionalities. --- lib/algora_web/live/org/dashboard_live.ex | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 680fabdbb..dd881aa00 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -427,24 +427,6 @@ defmodule AlgoraWeb.Org.DashboardLive do {create_tip(assigns)}
    - - <.section :if={@installations == [] && 1 == 2}> - <.card> - <.card_header> - <.card_title>GitHub Integration - <.card_description :if={@installations == []}> - Install the Algora app to enable slash commands in your GitHub repositories - - - <.card_content> -
    - <.button phx-click="install_app" class="ml-auto gap-2"> - Install GitHub App - -
    - - - {sidebar(assigns)} From e0937a31b5d2061acb4d8d3235bb11f792a8d752 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 12:58:55 +0200 Subject: [PATCH 31/63] add missing tip url --- lib/algora_web/live/org/dashboard_live.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index dd881aa00..d451fd875 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1114,6 +1114,12 @@ defmodule AlgoraWeb.Org.DashboardLive do
    <.input label="GitHub handle" field={@tip_form[:github_handle]} placeholder="jsmith" /> <.input label="Amount" icon="tabler-currency-dollar" field={@tip_form[:amount]} /> + <.input + label="URL" + field={@tip_form[:url]} + placeholder="https://github.com/owner/repo/issues/123" + helptext="We'll add a comment to the issue to notify the developer." + />

    Tip: You can also comment /tip $100 @username From 3a089885483866acbff893e532901f89056da5e9 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 13:11:41 +0200 Subject: [PATCH 32/63] feat: add repository form to HomeLive for GitHub URL submission - Introduced a new RepoForm module to handle GitHub repository URL input. - Implemented validation for the URL format to ensure it matches GitHub repository standards. - Added a form in the HomeLive view for users to submit their GitHub repository, enhancing user interaction. - Updated the mount function to include the new repo_form assignment for rendering. --- lib/algora_web/live/home_live.ex | 206 +++++++--------------- lib/algora_web/live/org/dashboard_live.ex | 1 + 2 files changed, 66 insertions(+), 141 deletions(-) diff --git a/lib/algora_web/live/home_live.ex b/lib/algora_web/live/home_live.ex index c3f698e36..fe469a1d3 100644 --- a/lib/algora_web/live/home_live.ex +++ b/lib/algora_web/live/home_live.ex @@ -15,6 +15,7 @@ defmodule AlgoraWeb.HomeLive do alias Algora.Workspace alias AlgoraWeb.Components.Footer alias AlgoraWeb.Components.Header + alias AlgoraWeb.Components.Logos alias AlgoraWeb.Components.Wordmarks alias AlgoraWeb.Data.PlatformStats alias AlgoraWeb.Forms.BountyForm @@ -22,6 +23,27 @@ defmodule AlgoraWeb.HomeLive do require Logger + defmodule RepoForm do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @primary_key false + embedded_schema do + field :url, :string + end + + def changeset(form, attrs) do + form + |> cast(attrs, [:url]) + |> validate_required([:url]) + |> validate_format(:url, ~r{^https?://github\.com/[^/]+/[^/\s]+$}i, + message: "Must be a valid GitHub repository URL (e.g. github.com/owner/repo)" + ) + end + end + @impl true def mount(%{"country_code" => country_code}, _session, socket) do Gettext.put_locale(AlgoraWeb.Gettext, Algora.Util.locale_from_country_code(country_code)) @@ -46,6 +68,7 @@ defmodule AlgoraWeb.HomeLive do |> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) # TODO: add url |> assign(:tip_form, to_form(TipForm.changeset(%TipForm{}, %{}))) + |> assign(:repo_form, to_form(RepoForm.changeset(%RepoForm{}, %{}))) |> assign(:pending_action, nil)} end @@ -64,29 +87,29 @@ defmodule AlgoraWeb.HomeLive do

    The open source Upwork for engineers

    -

    +

    Discover GitHub bounties, contract work and jobs

    -

    +

    Hire the top 1% open source developers

    -
    - <.button - navigate={~p"/onboarding/org"} - variant="default" - class="px-12 py-8 text-xl font-semibold" - > - Companies - - <.button - navigate={~p"/onboarding/dev"} - variant="secondary" - class="px-12 py-8 text-xl font-semibold" - > - Developers - -
    + <.form for={@repo_form} phx-submit="submit_repo" class="mt-10 w-full max-w-2xl"> +
    + <.input + field={@repo_form[:url]} + placeholder="https://github.com/your/repo" + class="w-full h-16 text-xl sm:text-2xl pl-[3.75rem] pr-48 border-emerald-500 font-display" + /> + + <.button + type="submit" + class="absolute right-2 top-2 bottom-2 px-8 h-[3rem] text-xl font-semibold" + > + Get Started + +
    +
    @@ -112,129 +135,6 @@ defmodule AlgoraWeb.HomeLive do
    - <%!--
    - <.pattern /> -
    -

    - Fund GitHub Issues -

    -

    - Support open source development with bounties on GitHub issues -

    - -
    - <.link - href="https://github.com/zed-industries/zed/issues/4440" - rel="noopener" - class="relative flex items-center gap-x-4 rounded-xl bg-black p-6 ring-1 ring-border transition-colors" - > -
    - Zed - Scott Chacon -
    -
    -
    - GitHub cofounder funds new feature in Zed Editor -
    -
    - Zed Editor, Scott Chacon -
    -
    - <.button size="lg" variant="secondary"> - View issue - - - - <.link - href="https://github.com/PX4/PX4-Autopilot/issues/22464" - rel="noopener" - class="relative flex items-center gap-x-4 rounded-xl bg-black p-6 ring-1 ring-border transition-colors" - > -
    - Alex Klimaj - PX4 - Andrew Wilkins -
    -
    -
    - DefenceTech CEOs fund obstacle avoidance in PX4 Drone Autopilot -
    -
    - Alex Klimaj, Founder of ARK Electronics & Andrew Wilkins, CEO of Ascend Engineering -
    -
    - <.button size="lg" variant="secondary"> - View issue - - - -
    -
    -
    - Fund any issue in seconds -
    -
    - Help improve the OSS you love and rely on -
    -
    -
    - <.icon name="tabler-check" class="h-4 w-4 mr-1 text-success-400" /> - Pay when PRs are merged -
    -
    - <.icon name="tabler-check" class="h-4 w-4 mr-1 text-success-400" /> - Pool bounties with other sponsors -
    -
    - <.icon name="tabler-check" class="h-4 w-4 mr-1 text-success-400" /> - Algora handles invoices, payouts, compliance & 1099s -
    -
    -
    - <.form - for={@bounty_form} - phx-submit="create_bounty" - class="col-span-3 grid grid-cols-3 gap-6 w-full" - > - <.input - label="URL" - field={@bounty_form[:url]} - placeholder="https://github.com/owner/repo/issues/1337" - /> - <.input - label="Amount" - icon="tabler-currency-dollar" - field={@bounty_form[:amount]} - class="placeholder:text-success" - /> -
    -
    No credit card required
    - <.button size="lg" class="w-full">Fund issue -
    - -
    -
    -
    -
    --%> -
    <.pattern />
    @@ -918,6 +818,30 @@ defmodule AlgoraWeb.HomeLive do end end + @impl true + def handle_event("submit_repo", %{"repo_form" => params}, socket) do + changeset = + %RepoForm{} + |> RepoForm.changeset(params) + |> Map.put(:action, :validate) + + if changeset.valid? do + # Extract owner and repo from URL + url = get_field(changeset, :url) + + case Regex.run(~r{github\.com/([^/]+)/([^/\s]+)}, url) do + [_, owner, repo] -> + {:noreply, push_navigate(socket, to: ~p"/go/#{owner}/#{repo}")} + + _ -> + {:noreply, + assign(socket, :repo_form, to_form(RepoForm.add_error(changeset, :url, "Invalid GitHub repository URL")))} + end + else + {:noreply, assign(socket, :repo_form, to_form(changeset))} + end + end + @impl true def handle_info({:authenticated, user}, socket) do socket = assign(socket, :current_user, user) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index d451fd875..2eb27f350 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1274,6 +1274,7 @@ defmodule AlgoraWeb.Org.DashboardLive do placeholder="https://github.com/owner/repo/issues/123" helptext="We'll add a comment to the issue to notify the developer." /> + <%!-- # TODO: implement --%> <.input label="Review (optional)" field={@tip_form[:message]} From 07ab2ba0f2947d8bae341db7806a7101d8e7256d Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 13:18:10 +0200 Subject: [PATCH 33/63] refactor: remove GitHub identity upsert from seeds file - Eliminated the upsert operation for GitHub identity in the seeds.exs file to streamline the seeding process. - This change focuses on maintaining relevant user data while simplifying the overall seed structure. --- priv/repo/seeds.exs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 82ecae125..89dbfadd4 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -45,19 +45,6 @@ erich = |> Map.merge(if(github_user && github_user["email"], do: %{email: github_user["email"]}, else: %{})) ) -upsert!( - :identity, - [:provider, :provider_id], - %{ - user_id: erich.id, - provider: erich.provider, - provider_id: erich.provider_id, - provider_email: erich.email, - provider_login: erich.handle, - provider_name: erich.name - } -) - richard = upsert!( :user, From e443c4b037f6f32400881d96d75e0dc432ca58cf Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 13:18:20 +0200 Subject: [PATCH 34/63] refactor: simplify class assignment in CoreComponents - Replaced inline class array with a `classes/1` function for cleaner and more maintainable code. - This change enhances readability and consistency in the component's styling logic. --- lib/algora_web/components/core_components.ex | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index 2e3ba6c48..a21f9638b 100644 --- a/lib/algora_web/components/core_components.ex +++ b/lib/algora_web/components/core_components.ex @@ -791,15 +791,17 @@ defmodule AlgoraWeb.CoreComponents do name={@name} id={@id || @name} value={Phoenix.HTML.Form.normalize_value(@type, @value)} - class={[ - "py-[7px] px-[11px] block w-full rounded-lg border-input bg-background", - "text-foreground focus:outline-none focus:ring-1 sm:text-sm sm:leading-6", - "border-input focus:border-ring focus:ring-ring", - @errors != [] && - "border-destructive placeholder-destructive-foreground/50 focus:border-destructive focus:ring-destructive/10", - @icon && "pl-10", - @class - ]} + class={ + classes([ + "py-[7px] px-[11px] block w-full rounded-lg border-input bg-background", + "text-foreground focus:outline-none focus:ring-1 sm:text-sm sm:leading-6", + "border-input focus:border-ring focus:ring-ring", + @errors != [] && + "border-destructive placeholder-destructive-foreground/50 focus:border-destructive focus:ring-destructive/10", + @icon && "pl-10", + @class + ]) + } autocomplete="off" {@rest} /> From 1a796d32c518241828624d3b881522e4b3b9c980 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 14:01:28 +0200 Subject: [PATCH 35/63] fix failing tests --- test/algora/accounts_test.exs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/algora/accounts_test.exs b/test/algora/accounts_test.exs index a95351ba6..0f42d51bc 100644 --- a/test/algora/accounts_test.exs +++ b/test/algora/accounts_test.exs @@ -14,10 +14,11 @@ defmodule Algora.AccountsTest do "name" => "Github User" } - {:ok, user} = Accounts.register_github_user(email, info, [email], "token123") - {:ok, user_again} = Accounts.register_github_user(email, info, [email], "token123") + {:ok, user} = Accounts.register_github_user(nil, email, info, [email], "token123") + {:ok, user_again} = Accounts.register_github_user(user, email, info, [email], "token123") - assert_activity_names([:identity_created]) + assert user.id == user_again.id + assert_activity_names([:identity_created, :identity_created]) assert_activity_names_for_user(user.id, [:identity_created]) assert_activity_names_for_user(user_again.id, [:identity_created]) end From 8269459107cf8b6991b702c32aa6bd68e8b3324f Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 15:22:52 +0200 Subject: [PATCH 36/63] refactor: simplify error handling in repository form - Updated the error assignment logic in the HomeLive module to streamline the handling of invalid GitHub repository URLs. - This change enhances code readability and maintains consistency in form error management. --- lib/algora_web/live/home_live.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/algora_web/live/home_live.ex b/lib/algora_web/live/home_live.ex index fe469a1d3..26f29a263 100644 --- a/lib/algora_web/live/home_live.ex +++ b/lib/algora_web/live/home_live.ex @@ -834,8 +834,7 @@ defmodule AlgoraWeb.HomeLive do {:noreply, push_navigate(socket, to: ~p"/go/#{owner}/#{repo}")} _ -> - {:noreply, - assign(socket, :repo_form, to_form(RepoForm.add_error(changeset, :url, "Invalid GitHub repository URL")))} + {:noreply, assign(socket, :repo_form, to_form(add_error(changeset, :url, "Invalid GitHub repository URL")))} end else {:noreply, assign(socket, :repo_form, to_form(changeset))} From b2784720c34889426fce031256283a49b586587e Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 15:37:41 +0200 Subject: [PATCH 37/63] feat: implement GitHub URL parsing and update form validation - Removed the previous URL format validation and replaced it with a new `parse_github_url/1` function to handle various GitHub repository URL formats. - Updated the repository form submission logic to utilize the new parsing function, improving error handling for invalid URLs. - Added comprehensive tests for the new URL parsing functionality to ensure robustness and accuracy in handling different input formats. --- lib/algora_web/live/home_live.ex | 26 ++++++++++------ test/algora_web/live/home_live_test.exs | 41 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 test/algora_web/live/home_live_test.exs diff --git a/lib/algora_web/live/home_live.ex b/lib/algora_web/live/home_live.ex index 26f29a263..3dfbecab5 100644 --- a/lib/algora_web/live/home_live.ex +++ b/lib/algora_web/live/home_live.ex @@ -38,9 +38,6 @@ defmodule AlgoraWeb.HomeLive do form |> cast(attrs, [:url]) |> validate_required([:url]) - |> validate_format(:url, ~r{^https?://github\.com/[^/]+/[^/\s]+$}i, - message: "Must be a valid GitHub repository URL (e.g. github.com/owner/repo)" - ) end end @@ -98,7 +95,7 @@ defmodule AlgoraWeb.HomeLive do
    <.input field={@repo_form[:url]} - placeholder="https://github.com/your/repo" + placeholder="github.com/your/repo" class="w-full h-16 text-xl sm:text-2xl pl-[3.75rem] pr-48 border-emerald-500 font-display" /> @@ -818,6 +815,13 @@ defmodule AlgoraWeb.HomeLive do end end + def parse_github_url(url) do + case Regex.run(~r{(?:github\.com/)?([^/\s]+)/([^/\s]+)}, url) do + [_, owner, repo] -> {:ok, {owner, repo}} + _ -> {:error, "Must be a valid GitHub repository URL (e.g. github.com/owner/repo) or owner/repo format"} + end + end + @impl true def handle_event("submit_repo", %{"repo_form" => params}, socket) do changeset = @@ -826,15 +830,19 @@ defmodule AlgoraWeb.HomeLive do |> Map.put(:action, :validate) if changeset.valid? do - # Extract owner and repo from URL url = get_field(changeset, :url) - case Regex.run(~r{github\.com/([^/]+)/([^/\s]+)}, url) do - [_, owner, repo] -> + case url |> parse_github_url() |> dbg() do + {:ok, {owner, repo}} -> {:noreply, push_navigate(socket, to: ~p"/go/#{owner}/#{repo}")} - _ -> - {:noreply, assign(socket, :repo_form, to_form(add_error(changeset, :url, "Invalid GitHub repository URL")))} + {:error, message} -> + {:noreply, + assign( + socket, + :repo_form, + to_form(add_error(changeset, :url, message)) + )} end else {:noreply, assign(socket, :repo_form, to_form(changeset))} diff --git a/test/algora_web/live/home_live_test.exs b/test/algora_web/live/home_live_test.exs new file mode 100644 index 000000000..7ac44f0c0 --- /dev/null +++ b/test/algora_web/live/home_live_test.exs @@ -0,0 +1,41 @@ +defmodule AlgoraWeb.HomeLiveTest do + use AlgoraWeb.ConnCase, async: true + + alias AlgoraWeb.HomeLive + + describe "parse_github_url/1" do + test "parses full GitHub URLs" do + assert HomeLive.parse_github_url("https://github.com/owner/repo") == {:ok, {"owner", "repo"}} + assert HomeLive.parse_github_url("http://github.com/owner/repo") == {:ok, {"owner", "repo"}} + assert HomeLive.parse_github_url("github.com/owner/repo") == {:ok, {"owner", "repo"}} + end + + test "parses owner/repo format" do + assert HomeLive.parse_github_url("owner/repo") == {:ok, {"owner", "repo"}} + end + + test "handles URLs with dashes and underscores" do + assert HomeLive.parse_github_url("my-org/my_repo") == {:ok, {"my-org", "my_repo"}} + assert HomeLive.parse_github_url("github.com/my-org/my_repo") == {:ok, {"my-org", "my_repo"}} + end + + test "handles numeric characters" do + assert HomeLive.parse_github_url("owner123/repo456") == {:ok, {"owner123", "repo456"}} + end + + test "rejects invalid formats" do + error_msg = "Must be a valid GitHub repository URL (e.g. github.com/owner/repo) or owner/repo format" + + assert HomeLive.parse_github_url("") == {:error, error_msg} + assert HomeLive.parse_github_url("invalid") == {:error, error_msg} + assert HomeLive.parse_github_url("owner") == {:error, error_msg} + assert HomeLive.parse_github_url("owner/") == {:error, error_msg} + assert HomeLive.parse_github_url("/repo") == {:error, error_msg} + end + + test "handles whitespace" do + assert HomeLive.parse_github_url(" owner/repo ") == {:ok, {"owner", "repo"}} + assert HomeLive.parse_github_url(" github.com/owner/repo ") == {:ok, {"owner", "repo"}} + end + end +end From 49daa75b8fa2a176490d101ab97b10d0150cd77c Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 15:43:09 +0200 Subject: [PATCH 38/63] feat: enhance repository creation logic in HomeLive - Updated the handling of GitHub URLs to include repository creation via the Workspace module. - Improved error logging for failed repository creation attempts, providing clearer feedback for users. - Streamlined the assignment of error messages in the repository form for better user experience. --- lib/algora_web/live/home_live.ex | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/algora_web/live/home_live.ex b/lib/algora_web/live/home_live.ex index 3dfbecab5..3fbc39957 100644 --- a/lib/algora_web/live/home_live.ex +++ b/lib/algora_web/live/home_live.ex @@ -833,16 +833,20 @@ defmodule AlgoraWeb.HomeLive do url = get_field(changeset, :url) case url |> parse_github_url() |> dbg() do - {:ok, {owner, repo}} -> - {:noreply, push_navigate(socket, to: ~p"/go/#{owner}/#{repo}")} + {:ok, {repo_owner, repo_name}} -> + token = Github.TokenPool.get_token() + + case Workspace.ensure_repository(token, repo_owner, repo_name) do + {:ok, _repo} -> + {:noreply, push_navigate(socket, to: ~p"/go/#{repo_owner}/#{repo_name}")} + + {:error, reason} -> + Logger.error("Failed to create repository: #{inspect(reason)}") + {:noreply, assign(socket, :repo_form, to_form(add_error(changeset, :url, "Repository not found")))} + end {:error, message} -> - {:noreply, - assign( - socket, - :repo_form, - to_form(add_error(changeset, :url, message)) - )} + {:noreply, assign(socket, :repo_form, to_form(add_error(changeset, :url, message)))} end else {:noreply, assign(socket, :repo_form, to_form(changeset))} From 745c5eb30201cfc18a7ded0f9b6c1413e57d7c0e Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 15:45:11 +0200 Subject: [PATCH 39/63] reorganize --- lib/algora/shared/util.ex | 7 +++++ lib/algora_web/live/home_live.ex | 9 +----- test/algora/shared/util_test.exs | 36 ++++++++++++++++++++++ test/algora_web/live/home_live_test.exs | 41 ------------------------- 4 files changed, 44 insertions(+), 49 deletions(-) delete mode 100644 test/algora_web/live/home_live_test.exs diff --git a/lib/algora/shared/util.ex b/lib/algora/shared/util.ex index b0683a903..aad4d2e75 100644 --- a/lib/algora/shared/util.ex +++ b/lib/algora/shared/util.ex @@ -122,4 +122,11 @@ defmodule Algora.Util do # TODO: Implement this for all countries def locale_from_country_code("gr"), do: "el" def locale_from_country_code(country_code), do: country_code + + def parse_github_url(url) do + case Regex.run(~r{(?:github\.com/)?([^/\s]+)/([^/\s]+)}, url) do + [_, owner, repo] -> {:ok, {owner, repo}} + _ -> {:error, "Must be a valid GitHub repository URL (e.g. github.com/owner/repo) or owner/repo format"} + end + end end diff --git a/lib/algora_web/live/home_live.ex b/lib/algora_web/live/home_live.ex index 3fbc39957..613fd6577 100644 --- a/lib/algora_web/live/home_live.ex +++ b/lib/algora_web/live/home_live.ex @@ -815,13 +815,6 @@ defmodule AlgoraWeb.HomeLive do end end - def parse_github_url(url) do - case Regex.run(~r{(?:github\.com/)?([^/\s]+)/([^/\s]+)}, url) do - [_, owner, repo] -> {:ok, {owner, repo}} - _ -> {:error, "Must be a valid GitHub repository URL (e.g. github.com/owner/repo) or owner/repo format"} - end - end - @impl true def handle_event("submit_repo", %{"repo_form" => params}, socket) do changeset = @@ -832,7 +825,7 @@ defmodule AlgoraWeb.HomeLive do if changeset.valid? do url = get_field(changeset, :url) - case url |> parse_github_url() |> dbg() do + case url |> Algora.Util.parse_github_url() |> dbg() do {:ok, {repo_owner, repo_name}} -> token = Github.TokenPool.get_token() diff --git a/test/algora/shared/util_test.exs b/test/algora/shared/util_test.exs index 9c60e6850..fc2acc717 100644 --- a/test/algora/shared/util_test.exs +++ b/test/algora/shared/util_test.exs @@ -18,4 +18,40 @@ defmodule Algora.UtilTest do assert Util.format_pct(Decimal.new("0.1050")) == "10.5%" end end + + describe "parse_github_url/1" do + test "parses full GitHub URLs" do + assert Util.parse_github_url("https://github.com/owner/repo") == {:ok, {"owner", "repo"}} + assert Util.parse_github_url("http://github.com/owner/repo") == {:ok, {"owner", "repo"}} + assert Util.parse_github_url("github.com/owner/repo") == {:ok, {"owner", "repo"}} + end + + test "parses owner/repo format" do + assert Util.parse_github_url("owner/repo") == {:ok, {"owner", "repo"}} + end + + test "handles URLs with dashes and underscores" do + assert Util.parse_github_url("my-org/my_repo") == {:ok, {"my-org", "my_repo"}} + assert Util.parse_github_url("github.com/my-org/my_repo") == {:ok, {"my-org", "my_repo"}} + end + + test "handles numeric characters" do + assert Util.parse_github_url("owner123/repo456") == {:ok, {"owner123", "repo456"}} + end + + test "rejects invalid formats" do + error_msg = "Must be a valid GitHub repository URL (e.g. github.com/owner/repo) or owner/repo format" + + assert Util.parse_github_url("") == {:error, error_msg} + assert Util.parse_github_url("invalid") == {:error, error_msg} + assert Util.parse_github_url("owner") == {:error, error_msg} + assert Util.parse_github_url("owner/") == {:error, error_msg} + assert Util.parse_github_url("/repo") == {:error, error_msg} + end + + test "handles whitespace" do + assert Util.parse_github_url(" owner/repo ") == {:ok, {"owner", "repo"}} + assert Util.parse_github_url(" github.com/owner/repo ") == {:ok, {"owner", "repo"}} + end + end end diff --git a/test/algora_web/live/home_live_test.exs b/test/algora_web/live/home_live_test.exs deleted file mode 100644 index 7ac44f0c0..000000000 --- a/test/algora_web/live/home_live_test.exs +++ /dev/null @@ -1,41 +0,0 @@ -defmodule AlgoraWeb.HomeLiveTest do - use AlgoraWeb.ConnCase, async: true - - alias AlgoraWeb.HomeLive - - describe "parse_github_url/1" do - test "parses full GitHub URLs" do - assert HomeLive.parse_github_url("https://github.com/owner/repo") == {:ok, {"owner", "repo"}} - assert HomeLive.parse_github_url("http://github.com/owner/repo") == {:ok, {"owner", "repo"}} - assert HomeLive.parse_github_url("github.com/owner/repo") == {:ok, {"owner", "repo"}} - end - - test "parses owner/repo format" do - assert HomeLive.parse_github_url("owner/repo") == {:ok, {"owner", "repo"}} - end - - test "handles URLs with dashes and underscores" do - assert HomeLive.parse_github_url("my-org/my_repo") == {:ok, {"my-org", "my_repo"}} - assert HomeLive.parse_github_url("github.com/my-org/my_repo") == {:ok, {"my-org", "my_repo"}} - end - - test "handles numeric characters" do - assert HomeLive.parse_github_url("owner123/repo456") == {:ok, {"owner123", "repo456"}} - end - - test "rejects invalid formats" do - error_msg = "Must be a valid GitHub repository URL (e.g. github.com/owner/repo) or owner/repo format" - - assert HomeLive.parse_github_url("") == {:error, error_msg} - assert HomeLive.parse_github_url("invalid") == {:error, error_msg} - assert HomeLive.parse_github_url("owner") == {:error, error_msg} - assert HomeLive.parse_github_url("owner/") == {:error, error_msg} - assert HomeLive.parse_github_url("/repo") == {:error, error_msg} - end - - test "handles whitespace" do - assert HomeLive.parse_github_url(" owner/repo ") == {:ok, {"owner", "repo"}} - assert HomeLive.parse_github_url(" github.com/owner/repo ") == {:ok, {"owner", "repo"}} - end - end -end From eb738c0aa28a99d9c164a69cba34afe4edd1c3ca Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 15:46:33 +0200 Subject: [PATCH 40/63] remove dbg --- lib/algora_web/live/home_live.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora_web/live/home_live.ex b/lib/algora_web/live/home_live.ex index 613fd6577..65fd62fa0 100644 --- a/lib/algora_web/live/home_live.ex +++ b/lib/algora_web/live/home_live.ex @@ -825,7 +825,7 @@ defmodule AlgoraWeb.HomeLive do if changeset.valid? do url = get_field(changeset, :url) - case url |> Algora.Util.parse_github_url() |> dbg() do + case Algora.Util.parse_github_url(url) do {:ok, {repo_owner, repo_name}} -> token = Github.TokenPool.get_token() From 2c56fc1671bb96b8a9c21e5d8318a5219dbd61cd Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 15:47:43 +0200 Subject: [PATCH 41/63] remove welcome msg --- lib/algora_web/controllers/org_preview_callback_controller.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/algora_web/controllers/org_preview_callback_controller.ex b/lib/algora_web/controllers/org_preview_callback_controller.ex index 71395f347..843207880 100644 --- a/lib/algora_web/controllers/org_preview_callback_controller.ex +++ b/lib/algora_web/controllers/org_preview_callback_controller.ex @@ -24,9 +24,7 @@ defmodule AlgoraWeb.OrgPreviewCallbackController do conn end - conn - |> put_flash(:info, "Welcome to Algora!") - |> AlgoraWeb.UserAuth.log_in_user(user) + AlgoraWeb.UserAuth.log_in_user(conn, user) else {:error, reason} -> Logger.debug("failed preview exchange #{inspect(reason)}") From 84c7d70e874834b4d11774db166b575204812e43 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 16:07:13 +0200 Subject: [PATCH 42/63] feat: add GitHub connection alert in dashboard - Introduced a conditional alert in the dashboard that prompts users to connect their GitHub account if they have uncompleted achievements related to GitHub status. - The alert includes a message and a button to close the share drawer, enhancing user guidance for creating a share drawer type. - Maintained existing functionality for displaying share drawer content when the GitHub connection is not required. --- lib/algora_web/live/org/dashboard_live.ex | 41 +++++++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 2eb27f350..47eadc8bb 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1379,13 +1379,40 @@ defmodule AlgoraWeb.Org.DashboardLive do <.form for={@contract_form} phx-change="validate_contract" phx-submit="create_contract">
    <.share_drawer_developer_info selected_developer={@selected_developer} /> - <.share_drawer_content - :if={@selected_developer} - share_drawer_type={@share_drawer_type} - contract_form={@contract_form} - tip_form={@tip_form} - bounty_form={@bounty_form} - /> + <%= if @achievements |> Enum.any?(& &1.id == :connect_github_status and &1.status != :completed ) do %> +
    +
    +
    + <.share_drawer_content + :if={@selected_developer} + share_drawer_type={@share_drawer_type} + contract_form={@contract_form} + tip_form={@tip_form} + bounty_form={@bounty_form} + /> +
    + <.alert + variant="default" + class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-20 w-auto flex flex-col items-center justify-center gap-2 text-center" + > + <.alert_title>Connect GitHub + <.alert_description> + Connect your GitHub account to create a {@share_drawer_type}. + + <.button phx-click="close_share_drawer" type="button" variant="subtle"> + Go back + + +
    + <% else %> + <.share_drawer_content + :if={@selected_developer} + share_drawer_type={@share_drawer_type} + contract_form={@contract_form} + tip_form={@tip_form} + bounty_form={@bounty_form} + /> + <% end %>
    From a0456e05564ec9e4ee49cd9c269dfff6f1d70dda Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 16:21:46 +0200 Subject: [PATCH 43/63] add view profile button --- lib/algora_web/live/org/dashboard_live.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 47eadc8bb..a04e79fd9 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1040,7 +1040,9 @@ defmodule AlgoraWeb.Org.DashboardLive do <.dropdown_menu_content> <.dropdown_menu_item> - View Profile + <.link href={User.url(@user)}> + View Profile + <.dropdown_menu_separator /> <.dropdown_menu_item phx-click="remove"> From 9bbc618ba636ee19ba5d07008f6cc7786dfa5796 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 16:21:54 +0200 Subject: [PATCH 44/63] add remove contributor button --- lib/algora_web/live/org/dashboard_live.ex | 28 +++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index a04e79fd9..af1a83f2d 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -463,6 +463,26 @@ defmodule AlgoraWeb.Org.DashboardLive do end} end + @impl true + def handle_event("remove_contributor", %{"user_id" => user_id}, socket) do + current_org = socket.assigns.current_org + + if incomplete?(socket.assigns.achievements, :install_app_status) do + {:noreply, put_flash(socket, :error, "Please install the app first")} + else + Repo.delete_all( + from c in Contributor, + where: c.user_id == ^user_id, + join: r in assoc(c, :repository), + join: u in assoc(r, :user), + where: u.provider == ^current_org.provider and u.provider_id == ^current_org.provider_id + ) + + contributors = Enum.reject(socket.assigns.contributors, &(&1.user.id == user_id)) + {:noreply, assign(socket, :contributors, contributors)} + end + end + def handle_event("create_bounty" = event, %{"bounty_form" => params} = unsigned_params, socket) do if socket.assigns.has_fresh_token? do changeset = @@ -888,6 +908,10 @@ defmodule AlgoraWeb.Org.DashboardLive do assign(socket, :achievements, Enum.reject(achievements, &(&1.status == :completed))) end + defp incomplete?(achievements, id) do + Enum.any?(achievements, &(&1.id == id and &1.status != :completed)) + end + defp personalize_status(_socket), do: :completed defp complete_signup_status(socket) do @@ -1045,7 +1069,7 @@ defmodule AlgoraWeb.Org.DashboardLive do <.dropdown_menu_separator /> - <.dropdown_menu_item phx-click="remove"> + <.dropdown_menu_item phx-click="remove_contributor" phx-value-user_id={@user.id}> Remove @@ -1381,7 +1405,7 @@ defmodule AlgoraWeb.Org.DashboardLive do <.form for={@contract_form} phx-change="validate_contract" phx-submit="create_contract">
    <.share_drawer_developer_info selected_developer={@selected_developer} /> - <%= if @achievements |> Enum.any?(& &1.id == :connect_github_status and &1.status != :completed ) do %> + <%= if incomplete?(@achievements, :connect_github_status) do %>
    From 7502934f10d2babc89afaf580e4e8c5f8cb520a7 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 16:49:20 +0200 Subject: [PATCH 45/63] refactor: unify form handling in DashboardLive - Consolidated form handling by replacing individual form assignments with a single `@form` variable for contract, tip, and bounty forms. - Updated the `create_tip` function to use `selected_developer` instead of fetching the recipient from the token. - Enhanced the share drawer logic to dynamically assign the correct form based on the selected share drawer type, improving code maintainability and readability. --- lib/algora_web/live/org/dashboard_live.ex | 76 ++++++++++++----------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index af1a83f2d..a12d97ff7 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -532,15 +532,16 @@ defmodule AlgoraWeb.Org.DashboardLive do |> Map.put(:action, :validate) with %{valid?: true} <- changeset, - {:ok, token} <- Accounts.get_access_token(socket.assigns.current_user), - {:ok, recipient} <- Workspace.ensure_user(token, get_field(changeset, :github_handle)), {:ok, checkout_url} <- - Bounties.create_tip(%{ - creator: socket.assigns.current_user, - owner: socket.assigns.current_org, - recipient: recipient, - amount: get_field(changeset, :amount) - }) do + Bounties.create_tip( + %{ + creator: socket.assigns.current_user, + owner: socket.assigns.current_org, + recipient: socket.assigns.selected_developer, + amount: get_field(changeset, :amount) + }, + ticket_ref: get_field(changeset, :ticket_ref) + ) do {:noreply, redirect(socket, external: checkout_url)} else %{valid?: false} -> @@ -579,16 +580,6 @@ defmodule AlgoraWeb.Org.DashboardLive do {:noreply, assign(socket, :show_share_drawer, false)} end - @impl true - def handle_event("validate_contract", %{"contract_form" => params}, socket) do - changeset = - %ContractForm{} - |> ContractForm.changeset(params) - |> Map.put(:action, :validate) - - {:noreply, assign(socket, :contract_form, to_form(changeset))} - end - @impl true def handle_event("create_contract", %{"contract_form" => params}, socket) do changeset = ContractForm.changeset(%ContractForm{}, params) @@ -1235,12 +1226,8 @@ defmodule AlgoraWeb.Org.DashboardLive do <.card_content>
    - <.input - label="Hourly Rate" - icon="tabler-currency-dollar" - field={@contract_form[:hourly_rate]} - /> - <.input label="Hours per Week" field={@contract_form[:hours_per_week]} /> + <.input label="Hourly Rate" icon="tabler-currency-dollar" field={@form[:hourly_rate]} /> + <.input label="Hours per Week" field={@form[:hours_per_week]} />
    @@ -1266,10 +1253,10 @@ defmodule AlgoraWeb.Org.DashboardLive do
    <.input label="URL" - field={@bounty_form[:url]} + field={@form[:url]} placeholder="https://github.com/owner/repo/issues/123" /> - <.input label="Amount" icon="tabler-currency-dollar" field={@bounty_form[:amount]} /> + <.input label="Amount" icon="tabler-currency-dollar" field={@form[:amount]} />
    @@ -1293,17 +1280,17 @@ defmodule AlgoraWeb.Org.DashboardLive do <.card_content>
    - <.input label="Amount" icon="tabler-currency-dollar" field={@tip_form[:amount]} /> + <.input label="Amount" icon="tabler-currency-dollar" field={@form[:amount]} /> <.input label="URL" - field={@tip_form[:url]} + field={@form[:url]} placeholder="https://github.com/owner/repo/issues/123" helptext="We'll add a comment to the issue to notify the developer." /> <%!-- # TODO: implement --%> <.input label="Review (optional)" - field={@tip_form[:message]} + field={@form[:message]} placeholder="Thanks for your great work!" />
    @@ -1394,6 +1381,27 @@ defmodule AlgoraWeb.Org.DashboardLive do end defp share_drawer(assigns) do + assigns = + case assigns.share_drawer_type do + nil -> + assigns + + "contract" -> + assigns + |> assign(:phx_submit, "create_contract") + |> assign(:form, assigns.contract_form) + + "tip" -> + assigns + |> assign(:phx_submit, "create_tip") + |> assign(:form, assigns.tip_form) + + "bounty" -> + assigns + |> assign(:phx_submit, "create_bounty") + |> assign(:form, assigns.bounty_form) + end + ~H""" <.drawer show={@show_share_drawer} direction="right" on_cancel="close_share_drawer"> <.share_drawer_header @@ -1402,7 +1410,7 @@ defmodule AlgoraWeb.Org.DashboardLive do share_drawer_type={@share_drawer_type} /> <.drawer_content :if={@selected_developer} class="mt-4"> - <.form for={@contract_form} phx-change="validate_contract" phx-submit="create_contract"> + <.form for={@form} phx-submit={@phx_submit}>
    <.share_drawer_developer_info selected_developer={@selected_developer} /> <%= if incomplete?(@achievements, :connect_github_status) do %> @@ -1412,9 +1420,7 @@ defmodule AlgoraWeb.Org.DashboardLive do <.share_drawer_content :if={@selected_developer} share_drawer_type={@share_drawer_type} - contract_form={@contract_form} - tip_form={@tip_form} - bounty_form={@bounty_form} + form={@form} />
    <.alert @@ -1434,9 +1440,7 @@ defmodule AlgoraWeb.Org.DashboardLive do <.share_drawer_content :if={@selected_developer} share_drawer_type={@share_drawer_type} - contract_form={@contract_form} - tip_form={@tip_form} - bounty_form={@bounty_form} + form={@form} /> <% end %>
    From 24788cbc7af468278ef8e38a5dbb3038114405af Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 17:03:18 +0200 Subject: [PATCH 46/63] refactor: enhance form handling and share drawer logic in DashboardLive - Updated the ticket reference structure in the tip creation process to ensure consistent data handling. - Replaced individual form assignments with a unified approach for contract, tip, and bounty forms, improving maintainability. - Enhanced the share drawer content rendering to dynamically utilize the appropriate form based on the selected share drawer type, streamlining the user experience. --- lib/algora_web/live/org/dashboard_live.ex | 261 +++++++++++----------- 1 file changed, 128 insertions(+), 133 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index a12d97ff7..3c463de02 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -499,7 +499,11 @@ defmodule AlgoraWeb.Org.DashboardLive do creator: socket.assigns.current_user, owner: socket.assigns.current_org, amount: amount, - ticket_ref: ticket_ref + ticket_ref: %{ + owner: ticket_ref.owner, + repo: ticket_ref.repo, + number: ticket_ref.number + } }) do {:noreply, socket @@ -528,9 +532,11 @@ defmodule AlgoraWeb.Org.DashboardLive do if socket.assigns.has_fresh_token? do changeset = %TipForm{} - |> TipForm.changeset(params) + |> TipForm.changeset(Map.put(params, "github_handle", socket.assigns.current_user.provider_login)) |> Map.put(:action, :validate) + ticket_ref = get_field(changeset, :ticket_ref) + with %{valid?: true} <- changeset, {:ok, checkout_url} <- Bounties.create_tip( @@ -540,7 +546,11 @@ defmodule AlgoraWeb.Org.DashboardLive do recipient: socket.assigns.selected_developer, amount: get_field(changeset, :amount) }, - ticket_ref: get_field(changeset, :ticket_ref) + ticket_ref: %{ + owner: ticket_ref.owner, + repo: ticket_ref.repo, + number: ticket_ref.number + } ) do {:noreply, redirect(socket, external: checkout_url)} else @@ -1220,91 +1230,95 @@ defmodule AlgoraWeb.Org.DashboardLive do defp share_drawer_content(%{share_drawer_type: "contract"} = assigns) do ~H""" - <.card> - <.card_header> - <.card_title>Contract Details - - <.card_content> -
    - <.input label="Hourly Rate" icon="tabler-currency-dollar" field={@form[:hourly_rate]} /> - <.input label="Hours per Week" field={@form[:hours_per_week]} /> -
    - - - -
    - <.button variant="secondary" phx-click="close_share_drawer" type="button"> - Cancel - - <.button type="submit"> - Send Contract Offer <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> - -
    + <.form for={@contract_form} phx-submit="create_contract"> + <.card> + <.card_header> + <.card_title>Contract Details + + <.card_content> +
    + <.input + label="Hourly Rate" + icon="tabler-currency-dollar" + field={@contract_form[:hourly_rate]} + /> + <.input label="Hours per Week" field={@contract_form[:hours_per_week]} /> +
    + + + +
    + <.button variant="secondary" phx-click="close_share_drawer" type="button"> + Cancel + + <.button type="submit"> + Send Contract Offer <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
    + """ end defp share_drawer_content(%{share_drawer_type: "bounty"} = assigns) do ~H""" - <.card> - <.card_header> - <.card_title>Bounty Details - - <.card_content> -
    - <.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" /> - -
    + <.form for={@bounty_form} phx-submit="create_bounty"> + <.card> + <.card_header> + <.card_title>Bounty Details + + <.card_content> +
    + <.input + label="URL" + field={@bounty_form[:url]} + placeholder="https://github.com/owner/repo/issues/123" + /> + <.input label="Amount" icon="tabler-currency-dollar" field={@bounty_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 defp share_drawer_content(%{share_drawer_type: "tip"} = assigns) do ~H""" - <.card> - <.card_header> - <.card_title>Tip Details - - <.card_content> -
    - <.input label="Amount" icon="tabler-currency-dollar" field={@form[:amount]} /> - <.input - label="URL" - field={@form[:url]} - placeholder="https://github.com/owner/repo/issues/123" - helptext="We'll add a comment to the issue to notify the developer." - /> - <%!-- # TODO: implement --%> - <.input - label="Review (optional)" - field={@form[:message]} - placeholder="Thanks for your great work!" - /> -
    - - - -
    - <.button variant="secondary" phx-click="close_share_drawer" type="button"> - Cancel - - <.button type="submit"> - Send Tip <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> - -
    + <.form for={@tip_form} phx-submit="create_tip"> + <.card> + <.card_header> + <.card_title>Tip Details + + <.card_content> +
    + <.input label="Amount" icon="tabler-currency-dollar" field={@tip_form[:amount]} /> + <.input + label="URL" + field={@tip_form[:url]} + placeholder="https://github.com/owner/repo/issues/123" + helptext="We'll add a comment to the issue to notify the developer." + /> +
    + + + +
    + <.button variant="secondary" phx-click="close_share_drawer" type="button"> + Cancel + + <.button type="submit"> + Send Tip <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
    + """ end @@ -1381,27 +1395,6 @@ defmodule AlgoraWeb.Org.DashboardLive do end defp share_drawer(assigns) do - assigns = - case assigns.share_drawer_type do - nil -> - assigns - - "contract" -> - assigns - |> assign(:phx_submit, "create_contract") - |> assign(:form, assigns.contract_form) - - "tip" -> - assigns - |> assign(:phx_submit, "create_tip") - |> assign(:form, assigns.tip_form) - - "bounty" -> - assigns - |> assign(:phx_submit, "create_bounty") - |> assign(:form, assigns.bounty_form) - end - ~H""" <.drawer show={@show_share_drawer} direction="right" on_cancel="close_share_drawer"> <.share_drawer_header @@ -1410,41 +1403,43 @@ defmodule AlgoraWeb.Org.DashboardLive do share_drawer_type={@share_drawer_type} /> <.drawer_content :if={@selected_developer} class="mt-4"> - <.form for={@form} phx-submit={@phx_submit}> -
    - <.share_drawer_developer_info selected_developer={@selected_developer} /> - <%= if incomplete?(@achievements, :connect_github_status) do %> -
    -
    -
    - <.share_drawer_content - :if={@selected_developer} - share_drawer_type={@share_drawer_type} - form={@form} - /> -
    - <.alert - variant="default" - class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-20 w-auto flex flex-col items-center justify-center gap-2 text-center" - > - <.alert_title>Connect GitHub - <.alert_description> - Connect your GitHub account to create a {@share_drawer_type}. - - <.button phx-click="close_share_drawer" type="button" variant="subtle"> - Go back - - +
    + <.share_drawer_developer_info selected_developer={@selected_developer} /> + <%= if incomplete?(@achievements, :connect_github_status) do %> +
    +
    +
    + <.share_drawer_content + :if={@selected_developer} + share_drawer_type={@share_drawer_type} + bounty_form={@bounty_form} + tip_form={@tip_form} + contract_form={@contract_form} + />
    - <% else %> - <.share_drawer_content - :if={@selected_developer} - share_drawer_type={@share_drawer_type} - form={@form} - /> - <% end %> -
    - + <.alert + variant="default" + class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-20 w-auto flex flex-col items-center justify-center gap-2 text-center" + > + <.alert_title>Connect GitHub + <.alert_description> + Connect your GitHub account to create a {@share_drawer_type}. + + <.button phx-click="close_share_drawer" type="button" variant="subtle"> + Go back + + +
    + <% else %> + <.share_drawer_content + :if={@selected_developer} + share_drawer_type={@share_drawer_type} + bounty_form={@bounty_form} + tip_form={@tip_form} + contract_form={@contract_form} + /> + <% end %> +
    """ From 6ed15ed30311a6b10a3644dd461944a446fa796e Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 17:06:48 +0200 Subject: [PATCH 47/63] refactor: update developer card rendering and button styles in DashboardLive - Modified the developer card rendering to pass the contract for each user directly, enhancing clarity and performance. - Updated button styles for better visual feedback, including hover and focus states, to improve user experience. - Simplified conditional rendering logic for contract-related buttons, ensuring consistent behavior across the dashboard. --- lib/algora_web/live/org/dashboard_live.ex | 28 ++++++++++++++--------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 3c463de02..6bec14370 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -223,7 +223,11 @@ defmodule AlgoraWeb.Org.DashboardLive do <%= for %Contributor{user: user} <- @contributors do %> - <.developer_card user={user} contracts={@contracts} current_org={@current_org} /> + <.developer_card + user={user} + contract_for_user={contract_for_user(@contracts, user)} + current_org={@current_org} + /> <% end %>
    @@ -239,7 +243,11 @@ defmodule AlgoraWeb.Org.DashboardLive do <%= for user <- @experts do %> - <.developer_card user={user} contracts={@contracts} current_org={@current_org} /> + <.developer_card + user={user} + contract_for_user={contract_for_user(@contracts, user)} + current_org={@current_org} + /> <% end %>
    @@ -1023,7 +1031,7 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@user.id} phx-value-type="bounty" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-600/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-600/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-800/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-800/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" > <.icon name="tabler-diamond" class="size-4 text-current mr-2 -ml-1" /> Bounty @@ -1032,28 +1040,26 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@user.id} phx-value-type="tip" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-red-600/10 hover:text-red-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-red-600/10 focus:text-red-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-red-400/50 focus:border-red-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-red-800/10 hover:text-red-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-red-800/10 focus:text-red-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-red-400/50 focus:border-red-400/50" > <.icon name="tabler-heart" class="size-4 text-current mr-2 -ml-1" /> Tip <.button - :if={contract_for_user(@contracts, @user)} - navigate={ - ~p"/org/#{@current_org.handle}/contracts/#{contract_for_user(@contracts, @user).id}" - } + :if={@contract_for_user} + navigate={~p"/org/#{@current_org.handle}/contracts/#{@contract_for_user.id}"} variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-600/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-600/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" > <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract <.button - :if={!contract_for_user(@contracts, @user)} + :if={!@contract_for_user} phx-click="share_opportunity" phx-value-user_id={@user.id} phx-value-type="contract" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-600/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-600/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" > <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract From 2c37c2eb00b92446df757d6d803b5221baec69a7 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 17:10:42 +0200 Subject: [PATCH 48/63] refactor: improve contract button rendering logic in DashboardLive - Enhanced conditional rendering for contract buttons based on user contract status, adding a new button for draft contracts. - Updated button styles for active and draft contracts to ensure consistent visual feedback and improve user experience. --- lib/algora_web/live/org/dashboard_live.ex | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 6bec14370..f64c22d32 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1046,13 +1046,21 @@ defmodule AlgoraWeb.Org.DashboardLive do <.button - :if={@contract_for_user} + :if={@contract_for_user && @contract_for_user.status == :active} navigate={~p"/org/#{@current_org.handle}/contracts/#{@contract_for_user.id}"} variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" + class="bg-emerald-800/10 text-emerald-400 drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-emerald-400/50 focus:border-emerald-400/50" > <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract + <.button + :if={@contract_for_user && @contract_for_user.status == :draft} + navigate={~p"/org/#{@current_org.handle}/contracts/#{@contract_for_user.id}"} + variant="none" + class="bg-gray-800/10 text-gray-400 drop-shadow-[0_1px_5px_#f8717180] focus:bg-gray-800/10 focus:text-gray-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-gray-400/50 focus:border-gray-400/50" + > + <.icon name="tabler-clock" class="size-4 text-current mr-2 -ml-1" /> Contract + <.button :if={!@contract_for_user} phx-click="share_opportunity" From 857b7084cd009916602cca08e2f7b3f9fcb5470a Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 17:16:18 +0200 Subject: [PATCH 49/63] refactor: enhance contract status handling in Contracts module - Updated the contract status type to include a new tuple format for querying multiple statuses. - Modified the query logic to support filtering contracts by a list of statuses, improving flexibility in contract retrieval. - Adjusted the DashboardLive module to fetch contracts based on the updated status handling, allowing for both draft and active contracts to be displayed. --- lib/algora/contracts/contracts.ex | 5 ++++- lib/algora/contracts/schemas/contract.ex | 2 ++ lib/algora_web/live/org/dashboard_live.ex | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/algora/contracts/contracts.ex b/lib/algora/contracts/contracts.ex index 96e501eab..2e4f6f8be 100644 --- a/lib/algora/contracts/contracts.ex +++ b/lib/algora/contracts/contracts.ex @@ -31,7 +31,7 @@ defmodule Algora.Contracts do | {:open?, true} | {:active_or_paid?, true} | {:original?, true} - | {:status, :draft | :active | :paid} + | {:status, Contract.status() | {:in, [Contract.status()]}} | {:after, non_neg_integer()} | {:before, non_neg_integer()} | {:order, :asc | :desc} @@ -684,6 +684,9 @@ defmodule Algora.Contracts do {:original?, true}, query -> from([c] in query, where: c.id == c.original_contract_id) + {:status, {:in, statuses}}, query -> + from([c] in query, where: c.status in ^statuses) + {:status, status}, query -> from([c] in query, where: c.status == ^status) diff --git a/lib/algora/contracts/schemas/contract.ex b/lib/algora/contracts/schemas/contract.ex index 631542520..592f54180 100644 --- a/lib/algora/contracts/schemas/contract.ex +++ b/lib/algora/contracts/schemas/contract.ex @@ -8,6 +8,8 @@ defmodule Algora.Contracts.Contract do alias Algora.MoneyUtils alias Algora.Validations + @type status :: :draft | :active | :paid | :cancelled | :disputed + typed_schema "contracts" do field :status, Ecto.Enum, values: [:draft, :active, :paid, :cancelled, :disputed] field :sequence_number, :integer, default: 1 diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index f64c22d32..f24cb4438 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -822,7 +822,7 @@ defmodule AlgoraWeb.Org.DashboardLive do end defp assign_contracts(socket) do - contracts = Contracts.list_contracts(client_id: socket.assigns.current_org.id, status: :draft) + contracts = Contracts.list_contracts(client_id: socket.assigns.current_org.id, status: {:in, [:draft, :active]}) assign(socket, :contracts, contracts) end From 358eebe904cdbcdd918e9935c8dc763a3929bafe Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 17:19:09 +0200 Subject: [PATCH 50/63] refactor: update button styles for improved visual feedback in DashboardLive - Modified button styles for bounty and contract buttons to enhance hover and focus effects, improving user experience. - Updated shadow colors for better visual distinction and consistency across the dashboard. --- lib/algora_web/live/org/dashboard_live.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index f24cb4438..e41653e97 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1031,7 +1031,7 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@user.id} phx-value-type="bounty" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-800/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-blue-800/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-800/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#60a5fa80] focus:bg-blue-800/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#60a5fa80] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" > <.icon name="tabler-diamond" class="size-4 text-current mr-2 -ml-1" /> Bounty @@ -1049,7 +1049,7 @@ defmodule AlgoraWeb.Org.DashboardLive do :if={@contract_for_user && @contract_for_user.status == :active} navigate={~p"/org/#{@current_org.handle}/contracts/#{@contract_for_user.id}"} variant="none" - class="bg-emerald-800/10 text-emerald-400 drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-emerald-400/50 focus:border-emerald-400/50" + class="bg-emerald-800/10 text-emerald-400 drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-emerald-400/50 focus:border-emerald-400/50" > <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract @@ -1067,7 +1067,7 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@user.id} phx-value-type="contract" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" > <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract From 141501e92c404a852d433d2edc964985804716b8 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 17:50:19 +0200 Subject: [PATCH 51/63] feat: enhance bounty management with visibility and shared_with attributes - Added visibility and shared_with fields to the Bounty schema, allowing for better control over bounty access. - Updated the bounty creation process to include these new attributes, enabling exclusive sharing with specified users. - Implemented alert notifications for exclusive bounties to inform shared users. - Adjusted related forms and queries to accommodate the new fields, improving overall functionality and user experience. --- lib/algora/admin/admin.ex | 11 +++ lib/algora/bounties/bounties.ex | 67 ++++++++++++++++--- lib/algora/bounties/jobs/notify_bounty.ex | 4 ++ lib/algora/bounties/schemas/bounty.ex | 5 +- lib/algora_web/forms/bounty_form.ex | 4 +- lib/algora_web/live/org/dashboard_live.ex | 24 ++++--- ...0321152115_add_shared_with_to_bounties.exs | 9 +++ 7 files changed, 104 insertions(+), 20 deletions(-) create mode 100644 priv/repo/migrations/20250321152115_add_shared_with_to_bounties.exs diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index 8a94553aa..25982da76 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -16,6 +16,17 @@ defmodule Algora.Admin do require Logger + def alert(message) do + %{ + title: "Alert: #{message}", + body: message, + name: "Algora Alert", + email: "info@algora.io" + } + |> Algora.Activities.SendEmail.changeset() + |> Repo.insert() + end + def token!, do: System.fetch_env!("ADMIN_GITHUB_TOKEN") def run(worker) do diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 26a614349..08a61dca4 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -35,6 +35,7 @@ defmodule Algora.Bounties do | {:tech_stack, [String.t()]} | {:before, %{inserted_at: DateTime.t(), id: String.t()}} | {:amount_gt, Money.t()} + | {:current_user_id, String.t()} def broadcast do Phoenix.PubSub.broadcast(Algora.PubSub, "bounties:all", :bounties_updated) @@ -44,16 +45,24 @@ defmodule Algora.Bounties do Phoenix.PubSub.subscribe(Algora.PubSub, "bounties:all") end - @spec do_create_bounty(%{creator: User.t(), owner: User.t(), amount: Money.t(), ticket: Ticket.t()}) :: + @spec do_create_bounty(%{ + creator: User.t(), + owner: User.t(), + amount: Money.t(), + ticket: Ticket.t(), + visibility: Bounty.visibility(), + shared_with: [String.t()] + }) :: {:ok, Bounty.t()} | {:error, atom()} - defp do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket}) do + defp do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket} = params) do changeset = Bounty.changeset(%Bounty{}, %{ amount: amount, ticket_id: ticket.id, owner_id: owner.id, creator_id: creator.id, - visibility: owner.bounty_mode + visibility: params[:visibility] || owner.bounty_mode, + shared_with: params[:shared_with] || [] }) changeset @@ -96,7 +105,9 @@ defmodule Algora.Bounties do strategy: strategy(), installation_id: integer(), command_id: integer(), - command_source: :ticket | :comment + command_source: :ticket | :comment, + visibility: Bounty.visibility() | nil, + shared_with: [String.t()] | nil ] ) :: {:ok, Bounty.t()} | {:error, atom()} @@ -119,9 +130,33 @@ defmodule Algora.Bounties do {:ok, strategy} <- strategy_to_action(existing, opts[:strategy]), {:ok, bounty} <- (case strategy do - :create -> do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket}) - :set -> existing |> Bounty.changeset(%{amount: amount}) |> Repo.update() - :increase -> existing |> Bounty.changeset(%{amount: Money.add!(existing.amount, amount)}) |> Repo.update() + :create -> + do_create_bounty(%{ + creator: creator, + owner: owner, + amount: amount, + ticket: ticket, + visibility: opts[:visibility], + shared_with: opts[:shared_with] + }) + + :set -> + existing + |> Bounty.changeset(%{ + amount: amount, + visibility: opts[:visibility], + shared_with: opts[:shared_with] + }) + |> Repo.update() + + :increase -> + existing + |> Bounty.changeset(%{ + amount: Money.add!(existing.amount, amount), + visibility: opts[:visibility], + shared_with: opts[:shared_with] + }) + |> Repo.update() end), {:ok, _job} <- notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, @@ -315,7 +350,10 @@ defmodule Algora.Bounties do ticket_ref: %{owner: ticket_ref.owner, repo: ticket_ref.repo, number: ticket_ref.number}, installation_id: opts[:installation_id], command_id: opts[:command_id], - command_source: opts[:command_source] + command_source: opts[:command_source], + bounty_id: bounty.id, + visibility: bounty.visibility, + shared_with: bounty.shared_with } |> Jobs.NotifyBounty.new() |> Oban.insert() @@ -1012,6 +1050,19 @@ defmodule Algora.Bounties do ) ) + {:current_user_id, user_id}, query -> + from([b] in query, + where: + b.visibility != :exclusive or + (b.visibility == :exclusive and + fragment( + "? = ANY(array(select unnest(?) || array[?]))", + ^user_id, + b.shared_with, + b.owner_id + )) + ) + _, query -> query end) diff --git a/lib/algora/bounties/jobs/notify_bounty.ex b/lib/algora/bounties/jobs/notify_bounty.ex index 48774232a..518898b90 100644 --- a/lib/algora/bounties/jobs/notify_bounty.ex +++ b/lib/algora/bounties/jobs/notify_bounty.ex @@ -11,6 +11,10 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do require Logger @impl Oban.Worker + def perform(%Oban.Job{args: %{"bounty_id" => bounty_id, "visibility" => "exclusive", "shared_with" => shared_with}}) do + Algora.Admin.alert("Notify exclusive bounty #{bounty_id} to #{inspect(shared_with)}") + end + def perform(%Oban.Job{ args: %{ "owner_login" => owner_login, diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex index 7d623d4c6..729f5b395 100644 --- a/lib/algora/bounties/schemas/bounty.ex +++ b/lib/algora/bounties/schemas/bounty.ex @@ -5,12 +5,15 @@ defmodule Algora.Bounties.Bounty do alias Algora.Accounts.User alias Algora.Bounties.Bounty + @type visibility :: :community | :exclusive | :public + typed_schema "bounties" do field :amount, Algora.Types.Money field :status, Ecto.Enum, values: [:open, :cancelled, :paid] field :number, :integer, default: 0 field :autopay_disabled, :boolean, default: false field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], default: :public + field :shared_with, {:array, :string}, default: [] belongs_to :ticket, Algora.Workspace.Ticket belongs_to :owner, User @@ -29,7 +32,7 @@ defmodule Algora.Bounties.Bounty do def changeset(bounty, attrs) do bounty - |> cast(attrs, [:amount, :ticket_id, :owner_id, :creator_id, :visibility]) + |> cast(attrs, [:amount, :ticket_id, :owner_id, :creator_id, :visibility, :shared_with]) |> validate_required([:amount, :ticket_id, :owner_id, :creator_id]) |> generate_id() |> foreign_key_constraint(:ticket) diff --git a/lib/algora_web/forms/bounty_form.ex b/lib/algora_web/forms/bounty_form.ex index 95e898144..7c44c8f88 100644 --- a/lib/algora_web/forms/bounty_form.ex +++ b/lib/algora_web/forms/bounty_form.ex @@ -10,6 +10,8 @@ defmodule AlgoraWeb.Forms.BountyForm do embedded_schema do field :url, :string field :amount, USD + field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], default: :public + field :shared_with, {:array, :string}, default: [] embeds_one :ticket_ref, TicketRef, primary_key: false do field :owner, :string @@ -21,7 +23,7 @@ defmodule AlgoraWeb.Forms.BountyForm do def changeset(form, attrs \\ %{}) do form - |> cast(attrs, [:url, :amount]) + |> cast(attrs, [:url, :amount, :visibility, :shared_with]) |> validate_required([:url, :amount]) |> Validations.validate_money_positive(:amount) |> Validations.validate_ticket_ref(:url, :ticket_ref) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index e41653e97..15714b5e5 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -503,16 +503,20 @@ defmodule AlgoraWeb.Org.DashboardLive do with %{valid?: true} <- changeset, {:ok, _bounty} <- - Bounties.create_bounty(%{ - creator: socket.assigns.current_user, - owner: socket.assigns.current_org, - amount: amount, - ticket_ref: %{ - owner: ticket_ref.owner, - repo: ticket_ref.repo, - number: ticket_ref.number - } - }) do + Bounties.create_bounty( + %{ + creator: socket.assigns.current_user, + owner: socket.assigns.current_org, + amount: amount, + ticket_ref: %{ + owner: ticket_ref.owner, + repo: ticket_ref.repo, + number: ticket_ref.number + } + }, + visibility: :exclusive, + shared_with: [socket.assigns.selected_developer.id] + ) do {:noreply, socket |> assign_achievements() diff --git a/priv/repo/migrations/20250321152115_add_shared_with_to_bounties.exs b/priv/repo/migrations/20250321152115_add_shared_with_to_bounties.exs new file mode 100644 index 000000000..9f6a33e76 --- /dev/null +++ b/priv/repo/migrations/20250321152115_add_shared_with_to_bounties.exs @@ -0,0 +1,9 @@ +defmodule Algora.Repo.Migrations.AddSharedWithToBounties do + use Ecto.Migration + + def change do + alter table(:bounties) do + add :shared_with, {:array, :citext}, default: [], null: false + end + end +end From 8ae8be7012e208cc023c01bbacd9b41f36a35de8 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 17:54:19 +0200 Subject: [PATCH 52/63] refactor: improve tech stack query handling in Bounties module - Added a clause to handle empty tech stack queries, ensuring that the query remains valid and functional. - Enhanced the overall query logic for better flexibility in bounty retrieval based on tech stack criteria. --- lib/algora/bounties/bounties.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 08a61dca4..5a543c1ce 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -1034,6 +1034,9 @@ defmodule Algora.Bounties do where: {b.inserted_at, b.id} < {^inserted_at, ^id} ) + {:tech_stack, []}, query -> + query + {:tech_stack, tech_stack}, query -> from([b, r: r] in query, where: fragment("? && ?::citext[]", r.tech_stack, ^tech_stack) From fb64261e40e7cf84dbc6174b43703af9419b00d2 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 17:54:32 +0200 Subject: [PATCH 53/63] refactor: simplify padding in inactive account section of DashboardLive - Removed unnecessary height class from the inactive account section, streamlining the layout. - Improved overall readability and maintainability of the code in the DashboardLive module. --- lib/algora_web/live/user/dashboard_live.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora_web/live/user/dashboard_live.ex b/lib/algora_web/live/user/dashboard_live.ex index 7e1bdd206..019236705 100644 --- a/lib/algora_web/live/user/dashboard_live.ex +++ b/lib/algora_web/live/user/dashboard_live.ex @@ -50,7 +50,7 @@ defmodule AlgoraWeb.User.DashboardLive do ~H"""
    -
    +
    <.section> <.card> <.card_header> From 57ceb31affe4c9dbac965925aeaeaab40f297523 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 18:18:15 +0200 Subject: [PATCH 54/63] refactor: streamline query options handling in DashboardLive - Updated the query options to use the current user directly, improving clarity and maintainability. - Removed commented-out tech stack and amount filters, simplifying the code structure. - Enhanced the assignment of bounties by utilizing the updated query options, ensuring better integration with the existing logic. --- lib/algora_web/live/user/dashboard_live.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/algora_web/live/user/dashboard_live.ex b/lib/algora_web/live/user/dashboard_live.ex index 019236705..0916c3ddd 100644 --- a/lib/algora_web/live/user/dashboard_live.ex +++ b/lib/algora_web/live/user/dashboard_live.ex @@ -276,7 +276,8 @@ defmodule AlgoraWeb.User.DashboardLive do {:noreply, socket |> assign(:tech_stack, tech_stack) - |> assign(:bounties, Bounties.list_bounties(tech_stack: tech_stack, limit: 10)) + |> assign(:query_opts, Keyword.put(socket.assigns.query_opts, :tech_stack, tech_stack)) + |> assign_bounties() |> push_event("clear-input", %{selector: "[phx-keydown='handle_tech_input']"})} end @@ -290,7 +291,8 @@ defmodule AlgoraWeb.User.DashboardLive do {:noreply, socket |> assign(:tech_stack, tech_stack) - |> assign(:bounties, Bounties.list_bounties(tech_stack: tech_stack, limit: 10))} + |> assign(:query_opts, Keyword.put(socket.assigns.query_opts, :tech_stack, tech_stack)) + |> assign_bounties()} end def handle_event("view_mode", %{"value" => mode}, socket) do From 5b00fd47acad11d74db9fb85e6a9b663cca61cca Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 18:22:32 +0200 Subject: [PATCH 55/63] refactor: update criteria handling in Bounties module - Changed the `:current_user_id` criterion to `:current_user`, improving type safety by using the User struct. - Simplified query logic for handling visibility based on the current user and owner ID, enhancing clarity and maintainability. - Adjusted the shared_with assignment in DashboardLive to handle different user scenarios, ensuring robust developer selection logic. --- lib/algora/bounties/bounties.ex | 53 ++++++++++++++-------- lib/algora_web/live/org/dashboard_live.ex | 7 ++- lib/algora_web/live/user/dashboard_live.ex | 1 + 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 5a543c1ce..7b029e511 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -35,7 +35,7 @@ defmodule Algora.Bounties do | {:tech_stack, [String.t()]} | {:before, %{inserted_at: DateTime.t(), id: String.t()}} | {:amount_gt, Money.t()} - | {:current_user_id, String.t()} + | {:current_user, User.t()} def broadcast do Phoenix.PubSub.broadcast(Algora.PubSub, "bounties:all", :bounties_updated) @@ -1017,13 +1017,39 @@ defmodule Algora.Bounties do :open -> query = where(query, [t: t], t.state == :open) - case criteria[:owner_id] do - nil -> - where(query, [b, o: o], b.visibility == :public and o.featured == true) + query = + case criteria[:current_user] do + nil -> + where(query, [b], b.visibility != :exclusive) + + user -> + dbg([user.id, user.email, to_string(user.provider_id)]) + + where( + query, + [b], + b.visibility != :exclusive or + (b.visibility == :exclusive and + fragment( + "? && ARRAY[?, ?, ?]::citext[]", + b.shared_with, + ^user.id, + ^user.email, + ^to_string(user.provider_id) + )) + ) + end + + query = + case criteria[:owner_id] do + nil -> + where(query, [b, o: o], (b.visibility == :public and o.featured == true) or b.visibility == :exclusive) + + _org_id -> + query + end - _org_id -> - where(query, [b], b.visibility in [:public, :community]) - end + query _ -> query @@ -1053,19 +1079,6 @@ defmodule Algora.Bounties do ) ) - {:current_user_id, user_id}, query -> - from([b] in query, - where: - b.visibility != :exclusive or - (b.visibility == :exclusive and - fragment( - "? = ANY(array(select unnest(?) || array[?]))", - ^user_id, - b.shared_with, - b.owner_id - )) - ) - _, query -> query end) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 15714b5e5..6949bb156 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -515,7 +515,12 @@ defmodule AlgoraWeb.Org.DashboardLive do } }, visibility: :exclusive, - shared_with: [socket.assigns.selected_developer.id] + shared_with: + case socket.assigns.selected_developer do + %User{handle: nil, provider_id: provider_id} -> [to_string(provider_id)] + %User{id: id} -> [id] + _ -> raise "Developer not selected" + end ) do {:noreply, socket diff --git a/lib/algora_web/live/user/dashboard_live.ex b/lib/algora_web/live/user/dashboard_live.ex index 0916c3ddd..9d2a0f731 100644 --- a/lib/algora_web/live/user/dashboard_live.ex +++ b/lib/algora_web/live/user/dashboard_live.ex @@ -27,6 +27,7 @@ defmodule AlgoraWeb.User.DashboardLive do query_opts = [ status: :open, limit: page_size(), + current_user: socket.assigns.current_user, tech_stack: socket.assigns.current_user.tech_stack, amount_gt: Money.new(:USD, 200) ] From 3571eeb659ce70935ba78e933cce4251ee2a6c1f Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 19:05:05 +0200 Subject: [PATCH 56/63] fix: migrate contracts in Accounts.migrate_user/1 --- lib/algora/accounts/accounts.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index 4afca47fd..9af874ae1 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -6,6 +6,7 @@ defmodule Algora.Accounts do alias Algora.Accounts.Identity alias Algora.Accounts.User alias Algora.Bounties.Bounty + alias Algora.Contracts.Contract alias Algora.Github alias Algora.Organizations alias Algora.Organizations.Member @@ -296,6 +297,11 @@ defmodule Algora.Accounts do set: [user_id: new_user.id] ) + Repo.update_all( + from(c in Contract, where: c.contractor_id == ^old_user.id), + set: [contractor_id: new_user.id] + ) + Repo.update_all( from(i in Installation, where: i.owner_id == ^old_user.id), set: [owner_id: new_user.id] From 574050913c43da0c909037bf77f5b8b4e8f561f0 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 19:05:52 +0200 Subject: [PATCH 57/63] refactor: enhance user update logic in Accounts module - Introduced a check for existing GitHub users during the update process to prevent conflicts. - Simplified the user parameter handling by consolidating the logic for setting user attributes. - Added validations for email and handle uniqueness, improving data integrity and user management. --- lib/algora/accounts/accounts.ex | 4 ++- lib/algora/accounts/schemas/user.ex | 55 ++++++++++++++--------------- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index 9af874ae1..b19b5368b 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -319,8 +319,10 @@ defmodule Algora.Accounts do end def update_user(user, info, primary_email, emails, token) do + old_user = Repo.get_by(User, provider: "github", provider_id: to_string(info["id"])) + Repo.transact(fn -> - if old_user = Repo.get_by(User, provider: "github", provider_id: to_string(info["id"])) do + if old_user && old_user.id != user.id do old_user |> change(provider: nil, provider_id: nil, provider_login: nil, provider_meta: nil) |> Repo.update() diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index fffcd284e..a6e743a23 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -177,38 +177,32 @@ defmodule Algora.Accounts.User do if identity_changeset.valid? do params = - case user.provider_id do - nil -> - %{ - "display_name" => info["name"], - "bio" => info["bio"], - "location" => info["location"], - "avatar_url" => info["avatar_url"], - "website_url" => info["blog"], - "github_url" => info["html_url"], - "provider" => "github", - "provider_id" => to_string(info["id"]), - "provider_login" => info["login"], - "provider_meta" => info - } - - _ -> - %{ - "display_name" => user.display_name || info["name"], - "bio" => user.bio || info["bio"], - "location" => user.location || info["location"], - "avatar_url" => user.avatar_url || info["avatar_url"], - "website_url" => user.website_url || info["blog"], - "github_url" => user.github_url || info["html_url"], - "provider" => "github", - "provider_id" => to_string(info["id"]), - "provider_login" => info["login"], - "provider_meta" => info - } + %{ + "handle" => user.handle || Algora.Organizations.ensure_unique_handle(info["login"]), + "email" => user.email || primary_email, + "display_name" => user.display_name || info["name"], + "bio" => user.bio || info["bio"], + "location" => user.location || info["location"], + "avatar_url" => user.avatar_url || info["avatar_url"], + "website_url" => user.website_url || info["blog"], + "github_url" => user.github_url || info["html_url"], + "provider" => "github", + "provider_id" => to_string(info["id"]), + "provider_login" => info["login"], + "provider_meta" => info + } + + params = + if is_nil(user.provider_id) do + Map.put(params, "display_name", info["name"]) + else + params end user |> cast(params, [ + :handle, + :email, :display_name, :bio, :location, @@ -221,8 +215,11 @@ defmodule Algora.Accounts.User do :provider_meta ]) |> generate_id() - |> validate_required([:display_name]) + |> validate_required([:email, :display_name, :handle]) + |> validate_handle() |> validate_email() + |> unique_constraint(:email) + |> unique_constraint(:handle) else user |> change() From e5b84c3c2d2f3418a9da8571aad06e5457e2c100 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 19:06:02 +0200 Subject: [PATCH 58/63] refactor: simplify padding in DashboardLive sections - Removed unnecessary height classes from the inactive account, contracts, and bounties sections, streamlining the layout. - Improved overall readability and maintainability of the code in the DashboardLive module. --- lib/algora_web/live/user/dashboard_live.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/algora_web/live/user/dashboard_live.ex b/lib/algora_web/live/user/dashboard_live.ex index 9d2a0f731..9cf26275a 100644 --- a/lib/algora_web/live/user/dashboard_live.ex +++ b/lib/algora_web/live/user/dashboard_live.ex @@ -51,7 +51,7 @@ defmodule AlgoraWeb.User.DashboardLive do ~H"""
    -
    +
    <.section> <.card> <.card_header> @@ -71,7 +71,7 @@ defmodule AlgoraWeb.User.DashboardLive do
    -
    0} class="relative h-full p-4 sm:p-6 md:p-8"> +
    0} class="p-4 sm:p-6 md:p-8">

    @@ -93,7 +93,7 @@ defmodule AlgoraWeb.User.DashboardLive do

    -
    0} class="relative h-full p-4 sm:p-6 md:p-8"> +
    0} class="p-4 sm:p-6 md:p-8"> <.section title="Open bounties" subtitle="Bounties for you">
    <.bounties bounties={@bounties} /> From 7b1655b047d86c648e1c18a12d19204e0cb6ee10 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 19:06:08 +0200 Subject: [PATCH 59/63] refactor: remove debug logging in Bounties module - Eliminated unnecessary debug statements related to user information, enhancing code cleanliness and maintainability. - Streamlined query handling by focusing on relevant logic without extraneous output. --- lib/algora/bounties/bounties.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 7b029e511..9d9882660 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -1023,8 +1023,6 @@ defmodule Algora.Bounties do where(query, [b], b.visibility != :exclusive) user -> - dbg([user.id, user.email, to_string(user.provider_id)]) - where( query, [b], From 7a6c1d91e1886dcbb8603bf4a15c593caf704e1d Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 19:16:02 +0200 Subject: [PATCH 60/63] refactor: enhance user update process in Accounts module - Updated the update_user function to include identity handling, improving the user update logic. - Consolidated the migration of old users to ensure data integrity during updates. - Streamlined the transaction process for better error handling and maintainability. --- lib/algora/accounts/accounts.ex | 65 ++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index b19b5368b..1aa33d55b 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -270,7 +270,7 @@ defmodule Algora.Accounts do case account do nil -> create_user(info, primary_email, emails, token) - {user, _identity} -> update_user(user, info, primary_email, emails, token) + {user, identity} -> update_user(user, identity, info, primary_email, emails, token) end end @@ -284,9 +284,45 @@ defmodule Algora.Accounts do |> Repo.insert() end - def migrate_user(old_user, new_user) do - Repo.delete_all(from(i in Identity, where: i.user_id == ^old_user.id)) + def update_user(user, identity, info, primary_email, emails, token) do + old_user = Repo.get_by(User, provider: "github", provider_id: to_string(info["id"])) + + identity_changeset = Identity.github_registration_changeset(user, info, primary_email, emails, token) + + user_changeset = User.github_registration_changeset(user, info, primary_email, emails, token) + + Repo.transact(fn -> + delete_result = + if identity do + Repo.delete(identity) + else + {:ok, nil} + end + migrate_result = + if old_user && old_user.id != user.id do + {:ok, old_user} = + old_user + |> change(provider: nil, provider_id: nil, provider_login: nil, provider_meta: nil) + |> Repo.update() + + # TODO: enqueue job + migrate_user(old_user, user) + + {:ok, old_user} + else + {:ok, nil} + end + + with {:ok, _} <- delete_result, + {:ok, _} <- migrate_result, + {:ok, _} <- Repo.insert(identity_changeset) do + Repo.update(user_changeset) + end + end) + end + + def migrate_user(old_user, new_user) do Repo.update_all( from(r in Repository, where: r.user_id == ^old_user.id), set: [user_id: new_user.id] @@ -318,29 +354,6 @@ defmodule Algora.Accounts do ) end - def update_user(user, info, primary_email, emails, token) do - old_user = Repo.get_by(User, provider: "github", provider_id: to_string(info["id"])) - - Repo.transact(fn -> - if old_user && old_user.id != user.id do - old_user - |> change(provider: nil, provider_id: nil, provider_login: nil, provider_meta: nil) - |> Repo.update() - - migrate_user(old_user, user) - end - - with {:ok, _} <- - user - |> Identity.github_registration_changeset(info, primary_email, emails, token) - |> Repo.insert() do - user - |> User.github_registration_changeset(info, primary_email, emails, token) - |> Repo.update() - end - end) - end - # def get_user_by_provider_email(provider, email) when provider in [:github] do # query = # from(u in User, From 08873959f615a74a1158c2da334aae64c52899a9 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 19:54:50 +0200 Subject: [PATCH 61/63] fix failing tests --- lib/algora/bounties/bounties.ex | 7 ++++--- lib/algora/bounties/schemas/bounty.ex | 4 ++-- .../20250321152115_add_shared_with_to_bounties.exs | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 9d9882660..83f43f45b 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -121,6 +121,7 @@ defmodule Algora.Bounties do opts \\ [] ) do command_id = opts[:command_id] + shared_with = opts[:shared_with] || [] Repo.transact(fn -> with {:ok, %{installation_id: installation_id, token: token}} <- @@ -137,7 +138,7 @@ defmodule Algora.Bounties do amount: amount, ticket: ticket, visibility: opts[:visibility], - shared_with: opts[:shared_with] + shared_with: shared_with }) :set -> @@ -145,7 +146,7 @@ defmodule Algora.Bounties do |> Bounty.changeset(%{ amount: amount, visibility: opts[:visibility], - shared_with: opts[:shared_with] + shared_with: shared_with }) |> Repo.update() @@ -154,7 +155,7 @@ defmodule Algora.Bounties do |> Bounty.changeset(%{ amount: Money.add!(existing.amount, amount), visibility: opts[:visibility], - shared_with: opts[:shared_with] + shared_with: shared_with }) |> Repo.update() end), diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex index 729f5b395..07f2a5ed1 100644 --- a/lib/algora/bounties/schemas/bounty.ex +++ b/lib/algora/bounties/schemas/bounty.ex @@ -12,8 +12,8 @@ defmodule Algora.Bounties.Bounty do field :status, Ecto.Enum, values: [:open, :cancelled, :paid] field :number, :integer, default: 0 field :autopay_disabled, :boolean, default: false - field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], default: :public - field :shared_with, {:array, :string}, default: [] + field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], null: false, default: :public + field :shared_with, {:array, :string}, null: false, default: [] belongs_to :ticket, Algora.Workspace.Ticket belongs_to :owner, User diff --git a/priv/repo/migrations/20250321152115_add_shared_with_to_bounties.exs b/priv/repo/migrations/20250321152115_add_shared_with_to_bounties.exs index 9f6a33e76..cadaf4327 100644 --- a/priv/repo/migrations/20250321152115_add_shared_with_to_bounties.exs +++ b/priv/repo/migrations/20250321152115_add_shared_with_to_bounties.exs @@ -3,7 +3,7 @@ defmodule Algora.Repo.Migrations.AddSharedWithToBounties do def change do alter table(:bounties) do - add :shared_with, {:array, :citext}, default: [], null: false + add :shared_with, {:array, :citext}, default: "{}", null: false end end end From 6f9094927d1a47bbad4e878cb10d8ecece21c2ac Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 20:01:34 +0200 Subject: [PATCH 62/63] refactor: adjust layout spacing in DashboardLive - Increased vertical spacing in the main container for improved visual hierarchy. - Reduced margin in the bounties section to enhance layout consistency and readability. --- lib/algora_web/live/org/dashboard_live.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 6949bb156..7bf264525 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -104,7 +104,7 @@ defmodule AlgoraWeb.Org.DashboardLive do def render(assigns) do ~H"""
    -
    +
    <.section :if={@payable_bounties != %{}}> <.card> <.card_header> @@ -255,10 +255,10 @@ defmodule AlgoraWeb.Org.DashboardLive do
    -
    +
    -

    Bounties

    +

    {@current_org.name} Bounties

    Create new bounties using the From d15b1e5cec572a4df87424e2c2ff5dd7ac3fefe4 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Mar 2025 20:21:12 +0200 Subject: [PATCH 63/63] feat: add follower count calculations in InstallationCallbackController - Introduced functions to retrieve and sum followers count for users, enhancing user data handling. - Implemented logic to determine if an organization should be featured based on total followers. - Updated organization changes to reflect the new featured status during installation handling. --- .../installation_callback_controller.ex | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lib/algora_web/controllers/installation_callback_controller.ex b/lib/algora_web/controllers/installation_callback_controller.ex index 7b3dcdc5b..433cc55f2 100644 --- a/lib/algora_web/controllers/installation_callback_controller.ex +++ b/lib/algora_web/controllers/installation_callback_controller.ex @@ -72,6 +72,25 @@ defmodule AlgoraWeb.InstallationCallbackController do end end + defp get_followers_count(token, user) do + if followers_count = user.provider_meta["followers_count"] do + followers_count + else + case Github.get_user(token, user.provider_id) do + {:ok, user} -> user["followers"] + _ -> 0 + end + end + end + + defp get_total_followers_count(token, users) do + users + |> Enum.map(&get_followers_count(token, &1)) + |> Enum.sum() + end + + defp featured_follower_threshold, do: 50 + defp do_handle_installation(conn, user, installation_id) do # TODO: replace :last_context with a new :last_installation_target field # TODO: handle nil user @@ -79,6 +98,8 @@ defmodule AlgoraWeb.InstallationCallbackController do with {:ok, access_token} <- Accounts.get_access_token(user), {:ok, installation} <- Github.find_installation(access_token, installation_id), {:ok, provider_user} <- Workspace.ensure_user(access_token, installation["account"]["login"]) do + total_followers_count = get_total_followers_count(access_token, [user, provider_user]) + case user.last_context do "preview/" <> ctx -> case String.split(ctx, "/") do @@ -107,6 +128,7 @@ defmodule AlgoraWeb.InstallationCallbackController do org |> change( handle: Organizations.ensure_unique_org_handle(installation["account"]["login"]), + featured: if(org.featured, do: true, else: total_followers_count > featured_follower_threshold()), provider: "github", provider_id: to_string(installation["account"]["id"]), provider_meta: Util.normalize_struct(installation["account"]) @@ -128,6 +150,12 @@ defmodule AlgoraWeb.InstallationCallbackController do last_context -> {:ok, org} = Organizations.fetch_org_by(handle: last_context) + + {:ok, org} = + org + |> change(featured: if(org.featured, do: true, else: total_followers_count > featured_follower_threshold())) + |> Repo.update() + {:ok, _} = Workspace.upsert_installation(installation, user, org, provider_user) {:ok, conn} end