diff --git a/.gitignore b/.gitignore index a8605f8e5..f809f53c8 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,5 @@ npm-debug.log /priv/db /priv/github /priv/migration -/priv/dev \ No newline at end of file +/priv/dev +/lib/algora_cloud* \ No newline at end of file diff --git a/.iex.exs b/.iex.exs index b4d6fb68e..6aa867895 100644 --- a/.iex.exs +++ b/.iex.exs @@ -15,6 +15,9 @@ alias Algora.Contracts alias Algora.Contracts.Contract alias Algora.Contracts.Timesheet alias Algora.Github +alias Algora.Jobs +alias Algora.Jobs.JobApplication +alias Algora.Jobs.JobPosting alias Algora.Organizations alias Algora.Organizations.Member alias Algora.Payments diff --git a/assets/js/app.ts b/assets/js/app.ts index 47de2a739..b783664a3 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -389,7 +389,9 @@ const Hooks = { }, DeriveDomain: { mounted() { - const domainInput = document.querySelector("[data-domain-source]"); + const domainInput = (this.el.closest("form") || document).querySelector( + "[data-domain-source]" + ); let shouldDerive = true; // Listen for manual edits to the domain field diff --git a/config/config.exs b/config/config.exs index 3078e4fc7..6ce095277 100644 --- a/config/config.exs +++ b/config/config.exs @@ -9,8 +9,7 @@ import Config config :algora, title: "Algora", - description: - "Discover GitHub bounties, contract work and jobs. Hire the top 1% open source developers.", + description: "Algora connects companies and engineers for full-time and contract work", ecto_repos: [Algora.Repo], generators: [timestamp_type: :utc_datetime_usec], redirects: [ @@ -57,7 +56,9 @@ config :algora, Oban, activity_notifier: 1, activity_mailer: 1, activity_discord: 10, - campaign_emails: 1 + campaign_emails: 1, + fetch_top_contributions: 1, + sync_contribution: 20 ] # Configures the mailer diff --git a/config/test.exs b/config/test.exs index 1624142b5..2f7f99a8d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -64,6 +64,8 @@ config :algora, :local_store, ttl: String.to_integer(System.get_env("LOCAL_STORE_TTL", "3600")), salt: System.get_env("LOCAL_STORE_SALT", "algora-local-store") +config :algora, :discord, webhook_url: System.get_env("DISCORD_WEBHOOK_URL") + config :algora, plausible_embed_url: System.get_env("PLAUSIBLE_EMBED_URL"), assets_url: System.get_env("ASSETS_URL"), diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index 53b3deee2..a52d9b2e9 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -43,6 +43,9 @@ defmodule Algora.Accounts do {:ids, ids}, query -> from([b] in query, where: b.id in ^ids) + {:limit, :infinity}, query -> + query + {:limit, limit}, query -> from([b] in query, limit: ^limit) diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index 31710ebf0..cd037063a 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -40,6 +40,7 @@ defmodule Algora.Accounts.User do field :priority, :integer, default: 0 field :fee_pct, :integer, default: 9 field :fee_pct_prev, :integer, default: 9 + field :subscription_price, Money field :seeded, :boolean, default: false field :activated, :boolean, default: false field :max_open_attempts, :integer, default: 3 @@ -51,6 +52,7 @@ defmodule Algora.Accounts.User do field :seeking_contracts, :boolean, default: false field :seeking_jobs, :boolean, default: false field :hiring, :boolean, default: false + field :hiring_subscription, Ecto.Enum, values: [:inactive, :trial, :active], default: :inactive field :hourly_rate_min, Money field :hourly_rate_max, Money @@ -81,6 +83,11 @@ defmodule Algora.Accounts.User do field :login_token, :string, virtual: true field :signup_token, :string, virtual: true + field :billing_name, :string + field :billing_address, :string + field :executive_name, :string + field :executive_role, :string + has_many :identities, Identity has_many :memberships, Member, foreign_key: :user_id has_many :members, Member, foreign_key: :org_id diff --git a/lib/algora/activities/activities.ex b/lib/algora/activities/activities.ex index 642a1c0d8..bd4481d30 100644 --- a/lib/algora/activities/activities.ex +++ b/lib/algora/activities/activities.ex @@ -300,7 +300,12 @@ defmodule Algora.Activities do discord_job = if discord_payload = DiscordViews.render(activity) do - [Algora.Activities.SendDiscord.changeset(%{payload: discord_payload})] + [ + Algora.Activities.SendDiscord.changeset(%{ + url: Algora.config([:discord, :webhook_url]), + payload: discord_payload + }) + ] else [] end diff --git a/lib/algora/activities/jobs/send_discord.ex b/lib/algora/activities/jobs/send_discord.ex index d094c8f01..99ddfeffe 100644 --- a/lib/algora/activities/jobs/send_discord.ex +++ b/lib/algora/activities/jobs/send_discord.ex @@ -11,7 +11,7 @@ defmodule Algora.Activities.SendDiscord do end @impl Oban.Worker - def perform(%Oban.Job{args: %{"payload" => payload}}) do - Algora.Discord.send_message(payload) + def perform(%Oban.Job{args: %{"url" => url, "payload" => payload}}) do + Algora.Discord.send_message(url, payload) end end diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index 62bc65cc8..12079a83c 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -1,5 +1,6 @@ defmodule Algora.Admin do @moduledoc false + import Ecto.Changeset import Ecto.Query alias Algora.Accounts @@ -10,6 +11,7 @@ defmodule Algora.Admin do alias Algora.Bounties.Bounty alias Algora.Bounties.Claim alias Algora.Github + alias Algora.Jobs.JobPosting alias Algora.Parser alias Algora.Payments alias Algora.Payments.Transaction @@ -22,6 +24,71 @@ defmodule Algora.Admin do require Logger + def seed_job(opts \\ %{}) do + with {:ok, user} <- Repo.fetch_by(User, handle: opts.org.handle), + {:ok, user} <- user |> change(opts.org) |> Repo.update(), + {:ok, job} <- + Repo.insert(%JobPosting{ + id: Nanoid.generate(), + user_id: user.id, + company_name: user.name, + company_url: user.website_url, + title: opts.title, + description: opts.description, + tech_stack: opts.tech_stack || Enum.take(user.tech_stack, 1) + }) do + dbg("#{AlgoraWeb.Endpoint.url()}/#{user.handle}/jobs/#{job.id}") + end + end + + def sync_contributions(opts \\ []) do + query = + User + |> where([u], not is_nil(u.handle)) + |> where([u], not is_nil(u.provider_login)) + |> where([u], u.type == :individual) + |> where([u], fragment("not exists (select 1 from user_contributions where user_contributions.user_id = ?)", u.id)) + + query = + if handles = opts[:handles] do + where(query, [u], u.handle in ^handles) + else + query + end + + query = + if limit = opts[:limit] do + limit(query, ^limit) + else + query + end + + Repo.transaction( + fn -> + if opts[:dry_run] do + query + |> Repo.stream() + |> Enum.to_list() + |> length() + |> IO.puts() + else + query + |> Repo.stream() + |> Enum.each(fn user -> + %{provider_login: user.provider_login} + |> Workspace.Jobs.FetchTopContributions.new() + |> Oban.insert() + |> case do + {:ok, _job} -> IO.puts("Enqueued job for #{user.provider_login}") + {:error, error} -> IO.puts("Failed to enqueue job for #{user.provider_login}: #{inspect(error)}") + end + end) + end + end, + timeout: :infinity + ) + end + def release_payment(tx_id) do Repo.transact(fn -> {_, [tx]} = @@ -402,6 +469,7 @@ defmodule Algora.Admin do Logger.error(message) %{ + url: Algora.config([:discord, :webhook_url]), payload: %{ embeds: [ %{ @@ -422,16 +490,19 @@ defmodule Algora.Admin do email_job = Algora.Activities.SendEmail.changeset(%{ - title: "Error: #{message}", + title: "#{message}", body: message, - name: "Algora Alert", + name: "Action required", email: "info@algora.io" }) discord_job = SendDiscord.changeset(%{ + url: Algora.Settings.get("discord_webhook_url")["critical"] || Algora.config([:discord, :webhook_url]), payload: %{ - embeds: [%{color: color(severity), title: "Error", description: message, timestamp: DateTime.utc_now()}] + embeds: [ + %{color: color(severity), title: "Action required", description: message, timestamp: DateTime.utc_now()} + ] } }) @@ -442,6 +513,7 @@ defmodule Algora.Admin do Logger.info(message) %{ + url: Algora.config([:discord, :webhook_url]), payload: %{ embeds: [ %{ diff --git a/lib/algora/cloud.ex b/lib/algora/cloud.ex new file mode 100644 index 000000000..27406be96 --- /dev/null +++ b/lib/algora/cloud.ex @@ -0,0 +1,24 @@ +defmodule Algora.Cloud do + @moduledoc false + + def top_contributions(github_handle) do + call(AlgoraCloud, :top_contributions, [github_handle]) + end + + def list_top_matches(opts \\ []) do + call(AlgoraCloud, :list_top_matches, [opts]) + end + + def get_contribution_score(tech_stack, user, contributions_map) do + call(AlgoraCloud, :get_contribution_score, [tech_stack, user, contributions_map]) + end + + defp call(module, function, args) do + if :code.which(module) == :non_existing do + # TODO: call algora API + {:error, :not_loaded} + else + apply(module, function, args) + end + end +end diff --git a/lib/algora/integrations/discord/client.ex b/lib/algora/integrations/discord/client.ex index 56db6ac58..09e71a10d 100644 --- a/lib/algora/integrations/discord/client.ex +++ b/lib/algora/integrations/discord/client.ex @@ -3,15 +3,8 @@ defmodule Algora.Discord.Client do require Logger - def webhook_url, do: Algora.config([:discord, :webhook_url]) - - def post(data) do - if url = webhook_url() do - do_post(url, data) - else - {:ok, nil} - end - end + def post(nil, _data), do: {:ok, nil} + def post(url, data), do: do_post(url, data) defp do_post(url, data) do headers = [{"Content-Type", "application/json"}] diff --git a/lib/algora/integrations/discord/discord.ex b/lib/algora/integrations/discord/discord.ex index 2ae0cf85a..9ce94ddfa 100644 --- a/lib/algora/integrations/discord/discord.ex +++ b/lib/algora/integrations/discord/discord.ex @@ -9,8 +9,6 @@ defmodule Algora.Discord do require Logger - defdelegate webhook_url, to: Client - @doc """ Sends a message to a Discord channel. @@ -21,10 +19,10 @@ defmodule Algora.Discord do ## Examples - iex> Discord.send_message(%{content: "Hello, world!"}) + iex> Discord.send_message("https://discord.com/api/webhooks/1234567890/abcdefg", %{content: "Hello, world!"}) {:ok, response} - iex> Discord.send_message(%{ + iex> Discord.send_message("https://discord.com/api/webhooks/1234567890/abcdefg", %{ ...> embeds: [ ...> %{ ...> color: 0x6366f1, @@ -41,15 +39,15 @@ defmodule Algora.Discord do ...> }) {:ok, response} """ - @spec send_message(map()) :: {:ok, map() | nil} | {:error, any()} - def send_message(input) do + @spec send_message(String.t(), map()) :: {:ok, map() | nil} | {:error, any()} + def send_message(url, input) do input = Map.merge( %{username: "Algora.io", avatar_url: "https://algora.io/asset/storage/v1/object/public/images/logo-256px.png"}, input ) - case Client.post(input) do + case Client.post(url, input) do {:ok, response} -> {:ok, response} diff --git a/lib/algora/integrations/github/token_pool.ex b/lib/algora/integrations/github/token_pool.ex index f68445d21..94ffae081 100644 --- a/lib/algora/integrations/github/token_pool.ex +++ b/lib/algora/integrations/github/token_pool.ex @@ -15,7 +15,14 @@ defmodule Algora.Github.TokenPool do end def get_token do - GenServer.call(__MODULE__, :get_token) + case maybe_get_token() do + token when is_binary(token) -> token + _ -> get_token() + end + end + + def maybe_get_token do + GenServer.call(__MODULE__, :maybe_get_token) end def refresh_tokens do @@ -35,7 +42,7 @@ defmodule Algora.Github.TokenPool do end @impl true - def handle_call(:get_token, _from, %{current_token_index: index, tokens: tokens} = state) do + def handle_call(:maybe_get_token, _from, %{current_token_index: index, tokens: tokens} = state) do token = Enum.at(tokens, index) if token == nil do @@ -44,7 +51,13 @@ defmodule Algora.Github.TokenPool do next_index = rem(index + 1, length(tokens)) if next_index == 0, do: refresh_tokens() - {:reply, token, %{state | current_token_index: next_index}} + case Github.get_current_user(token) do + {:ok, _} -> + {:reply, token, %{state | current_token_index: next_index}} + + _ -> + {:reply, nil, %{state | current_token_index: next_index}} + end end end diff --git a/lib/algora/jobs/jobs.ex b/lib/algora/jobs/jobs.ex index ac4c92aaf..34d657152 100644 --- a/lib/algora/jobs/jobs.ex +++ b/lib/algora/jobs/jobs.ex @@ -4,7 +4,7 @@ defmodule Algora.Jobs do import Ecto.Changeset import Ecto.Query - alias Algora.Accounts + alias Algora.Accounts.User alias Algora.Bounties.LineItem alias Algora.Jobs.JobApplication alias Algora.Jobs.JobPosting @@ -15,12 +15,11 @@ defmodule Algora.Jobs do require Logger - def price, do: Money.new(:USD, 499, no_fraction_if_integer: true) - def list_jobs(opts \\ []) do JobPosting |> where([j], j.status == :active) |> order_by([j], desc: j.inserted_at) + |> maybe_filter_by_user(opts[:user_id]) |> maybe_filter_by_tech_stack(opts[:tech_stack]) |> maybe_limit(opts[:limit]) |> Repo.all() @@ -33,6 +32,12 @@ defmodule Algora.Jobs do |> Repo.insert() end + defp maybe_filter_by_user(query, nil), do: query + + defp maybe_filter_by_user(query, user_id) do + where(query, [j], j.user_id == ^user_id) + end + defp maybe_filter_by_tech_stack(query, nil), do: query defp maybe_filter_by_tech_stack(query, []), do: query @@ -43,28 +48,35 @@ defmodule Algora.Jobs do defp maybe_limit(query, nil), do: query defp maybe_limit(query, limit), do: limit(query, ^limit) - @spec create_payment_session(job_posting: JobPosting.t()) :: + @spec create_payment_session(User.t() | nil, JobPosting.t(), Money.t()) :: {:ok, String.t()} | {:error, atom()} - def create_payment_session(job_posting) do - line_items = [%LineItem{amount: price(), title: "Job posting - #{job_posting.company_name}"}] + def create_payment_session(user, job_posting, amount) do + line_items = [ + %LineItem{ + amount: amount, + title: "Algora Annual Subscription", + description: "Hiring services annual package" + }, + %LineItem{ + amount: Money.mult!(amount, Decimal.new("0.04")), + title: "Processing fee (4%)" + } + ] gross_amount = LineItem.gross_amount(line_items) group_id = Nanoid.generate() + job_posting = Repo.preload(job_posting, :user) + Repo.transact(fn -> - with {:ok, user} <- - Accounts.get_or_register_user(job_posting.email, %{ - type: :organization, - display_name: job_posting.company_name - }), - {:ok, _charge} <- + with {:ok, _charge} <- %Transaction{} |> change(%{ id: Nanoid.generate(), provider: "stripe", type: :charge, status: :initialized, - user_id: user.id, + user_id: if(user, do: user.id), job_id: job_posting.id, gross_amount: gross_amount, net_amount: gross_amount, @@ -86,20 +98,28 @@ defmodule Algora.Jobs do description: "Job posting - #{job_posting.company_name}", metadata: %{"version" => Payments.metadata_version(), "group_id" => group_id} }, - success_url: "#{AlgoraWeb.Endpoint.url()}/jobs?status=paid", - cancel_url: "#{AlgoraWeb.Endpoint.url()}/jobs?status=canceled" + success_url: + "#{AlgoraWeb.Endpoint.url()}/#{job_posting.user.handle}/jobs/#{job_posting.id}/applicants?status=paid", + cancel_url: "#{AlgoraWeb.Endpoint.url()}/#{job_posting.user.handle}/jobs/#{job_posting.id}/applicants" ) do {:ok, session.url} end end) end - def create_application(job_id, user) do - %JobApplication{} - |> JobApplication.changeset(%{job_id: job_id, user_id: user.id}) + def create_application(job_id, user, attrs \\ %{}) do + %JobApplication{job_id: job_id, user_id: user.id} + |> JobApplication.changeset(attrs) |> Repo.insert() end + def ensure_application(job_id, user, attrs \\ %{}) do + case JobApplication |> where([a], a.job_id == ^job_id and a.user_id == ^user.id) |> Repo.one() do + nil -> create_application(job_id, user, attrs) + application -> {:ok, application} + end + end + def list_user_applications(user) do JobApplication |> where([a], a.user_id == ^user.id) @@ -107,4 +127,18 @@ defmodule Algora.Jobs do |> Repo.all() |> MapSet.new() end + + def get_job_posting(id) do + case JobPosting |> Repo.get(id) |> Repo.preload(:user) do + nil -> {:error, :not_found} + job -> {:ok, job} + end + end + + def list_job_applications(job) do + JobApplication + |> where([a], a.job_id == ^job.id) + |> preload(:user) + |> Repo.all() + end end diff --git a/lib/algora/jobs/schemas/job_application.ex b/lib/algora/jobs/schemas/job_application.ex index c5d8984d5..6b3558b28 100644 --- a/lib/algora/jobs/schemas/job_application.ex +++ b/lib/algora/jobs/schemas/job_application.ex @@ -7,7 +7,7 @@ defmodule Algora.Jobs.JobApplication do typed_schema "job_applications" do field :status, Ecto.Enum, values: [:pending], null: false, default: :pending - + field :imported_at, :utc_datetime_usec belongs_to :job, JobPosting, null: false belongs_to :user, User, null: false @@ -16,7 +16,7 @@ defmodule Algora.Jobs.JobApplication do def changeset(job_application, attrs) do job_application - |> cast(attrs, [:status, :job_id, :user_id]) + |> cast(attrs, [:status, :job_id, :user_id, :imported_at]) |> generate_id() |> validate_required([:status, :job_id, :user_id]) |> unique_constraint([:job_id, :user_id]) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index e98e65cf2..a7a27bec3 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -35,7 +35,7 @@ defmodule Algora.Payments do end @spec create_stripe_session( - user :: User.t(), + user :: User.t() | nil, line_items :: [PSP.Session.line_item_data()], payment_intent_data :: PSP.Session.payment_intent_data(), opts :: [ @@ -44,7 +44,29 @@ defmodule Algora.Payments do ] ) :: {:ok, PSP.session()} | {:error, PSP.error()} - def create_stripe_session(user, line_items, payment_intent_data, opts \\ []) do + def create_stripe_session(user, line_items, payment_intent_data, opts \\ []) + + def create_stripe_session(nil, line_items, payment_intent_data, opts) do + opts = %{ + mode: "payment", + billing_address_collection: "required", + line_items: line_items, + success_url: opts[:success_url] || "#{AlgoraWeb.Endpoint.url()}/payment/success", + cancel_url: opts[:cancel_url] || "#{AlgoraWeb.Endpoint.url()}/payment/canceled", + payment_intent_data: payment_intent_data + } + + opts = + if payment_intent_data[:capture_method] == :manual do + opts + else + Map.put(opts, :invoice_creation, %{enabled: true}) + end + + PSP.Session.create(opts) + end + + def create_stripe_session(user, line_items, payment_intent_data, opts) do with {:ok, customer} <- fetch_or_create_customer(user) do opts = %{ mode: "payment", @@ -657,10 +679,16 @@ defmodule Algora.Payments do Repo.update_all(from(c in Claim, where: c.id in ^claim_ids), set: [status: :approved]) {_, job_postings} = - Repo.update_all(from(j in JobPosting, where: j.id in ^job_ids, select: j), set: [status: :processing]) + Repo.update_all(from(j in JobPosting, where: j.id in ^job_ids, select: j), set: [status: :active]) + + job_postings = Repo.preload(job_postings, :user) + + Repo.update_all(from(u in User, where: u.id in ^Enum.map(job_postings, & &1.user_id)), + set: [hiring_subscription: :active] + ) for job <- job_postings do - Algora.Admin.alert("Job payment received! #{job.company_name} #{job.email} #{job.url}", :info) + Algora.Admin.alert("Job payment received! #{job.company_name} #{job.email} #{job.url}", :critical) end auto_txs = diff --git a/lib/algora/settings/settings.ex b/lib/algora/settings/settings.ex index 21f590963..54b9b164e 100644 --- a/lib/algora/settings/settings.ex +++ b/lib/algora/settings/settings.ex @@ -91,6 +91,17 @@ defmodule Algora.Settings do set("org_matches:#{org_handle}", %{"matches" => matches}) end + def get_job_matches(job_id) do + case get("job_matches:#{job_id}") do + %{"matches" => matches} when is_list(matches) -> load_matches(matches) + _ -> [] + end + end + + def set_job_matches(job_id, matches) when is_binary(job_id) and is_list(matches) do + set("job_matches:#{job_id}", %{"matches" => matches}) + end + def get_tech_matches(tech) do case get("tech_matches:#{String.downcase(tech)}") do %{"matches" => matches} when is_list(matches) -> load_matches(matches) @@ -104,8 +115,9 @@ defmodule Algora.Settings do def load_matches(matches) do user_map = - [handles: Enum.map(matches, & &1["handle"])] + [handles: Enum.map(matches, & &1["handle"]), limit: :infinity] |> Accounts.list_developers() + |> Enum.filter(& &1.provider_login) |> Map.new(fn user -> {user.handle, user} end) Enum.flat_map(matches, fn match -> @@ -152,4 +164,29 @@ defmodule Algora.Settings do def set_featured_transactions(ids) when is_list(ids) do set("featured_transactions", %{"ids" => ids}) end + + def get_wire_details do + case get("wire_details") do + %{"details" => details} when is_map(details) -> details + _ -> nil + end + end + + def set_wire_details(details) when is_map(details) do + set("wire_details", %{"details" => details}) + end + + def get_subscription_price do + case get("subscription") do + %{"price" => %{"amount" => _amount, "currency" => _currency} = price} -> + Algora.MoneyUtils.deserialize(price) + + _ -> + nil + end + end + + def set_subscription_price(price) do + set("subscription", %{"price" => Algora.MoneyUtils.serialize(price)}) + end end diff --git a/lib/algora/shared/money_utils.ex b/lib/algora/shared/money_utils.ex index 42236ad37..8af4b3ace 100644 --- a/lib/algora/shared/money_utils.ex +++ b/lib/algora/shared/money_utils.ex @@ -30,4 +30,14 @@ defmodule Algora.MoneyUtils do _ -> struct end end + + def serialize(money) do + %{currency: money.currency, amount: money.amount} + end + + def deserialize(%{"currency" => currency, "amount" => amount}) do + Money.new!(currency, amount, no_fraction_if_integer: true) + end + + def deserialize(_), do: nil end diff --git a/lib/algora/workspace/jobs/fetch_top_contributions.ex b/lib/algora/workspace/jobs/fetch_top_contributions.ex new file mode 100644 index 000000000..2cbec30e2 --- /dev/null +++ b/lib/algora/workspace/jobs/fetch_top_contributions.ex @@ -0,0 +1,17 @@ +defmodule Algora.Workspace.Jobs.FetchTopContributions do + @moduledoc false + use Oban.Worker, + queue: :fetch_top_contributions, + max_attempts: 3, + # 30 days + unique: [period: 30 * 24 * 60 * 60, fields: [:args]] + + alias Algora.Github + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"provider_login" => provider_login}}) do + Algora.Workspace.fetch_top_contributions_async(Github.TokenPool.get_token(), provider_login) + end + + def timeout(_), do: :timer.seconds(30) +end diff --git a/lib/algora/workspace/jobs/sync_contribution.ex b/lib/algora/workspace/jobs/sync_contribution.ex new file mode 100644 index 000000000..9974d5067 --- /dev/null +++ b/lib/algora/workspace/jobs/sync_contribution.ex @@ -0,0 +1,25 @@ +defmodule Algora.Workspace.Jobs.SyncContribution do + @moduledoc false + use Oban.Worker, + queue: :sync_contribution, + max_attempts: 3, + # 30 days + unique: [period: 30 * 24 * 60 * 60, fields: [:args]] + + alias Algora.Github + alias Algora.Workspace + + @impl Oban.Worker + def perform(%Oban.Job{ + args: %{"user_id" => user_id, "repo_full_name" => repo_full_name, "contribution_count" => contribution_count} + }) do + token = Github.TokenPool.get_token() + + with [repo_owner, repo_name] <- String.split(repo_full_name, "/"), + {:ok, repo} <- Workspace.ensure_repository(token, repo_owner, repo_name), + {:ok, _tech_stack} <- Workspace.ensure_repo_tech_stack(token, repo), + {:ok, _contribution} <- Workspace.upsert_user_contribution(user_id, repo.id, contribution_count) do + :ok + end + end +end diff --git a/lib/algora/workspace/schemas/repository.ex b/lib/algora/workspace/schemas/repository.ex index 56fbaa79a..56404d7d9 100644 --- a/lib/algora/workspace/schemas/repository.ex +++ b/lib/algora/workspace/schemas/repository.ex @@ -17,6 +17,7 @@ defmodule Algora.Workspace.Repository do field :tech_stack, {:array, :string}, null: false, default: [] field :og_image_url, :string, null: false field :og_image_updated_at, :utc_datetime_usec + field :stargazers_count, :integer, null: false, default: 0 has_many :tickets, Algora.Workspace.Ticket has_many :activities, {"repository_activities", Activity}, foreign_key: :assoc_id @@ -40,11 +41,21 @@ defmodule Algora.Workspace.Repository do og_image_url: default_og_image_url(meta["owner"]["login"], meta["name"]), og_image_updated_at: DateTime.utc_now(), url: meta["html_url"], + stargazers_count: meta["stargazers_count"], user_id: user.id } %Repository{provider: "github", provider_meta: meta} - |> cast(params, [:provider_id, :name, :url, :description, :og_image_url, :og_image_updated_at, :user_id]) + |> cast(params, [ + :provider_id, + :name, + :url, + :description, + :og_image_url, + :og_image_updated_at, + :stargazers_count, + :user_id + ]) |> generate_id() |> validate_required([:provider_id, :name, :url, :user_id]) |> foreign_key_constraint(:user_id) diff --git a/lib/algora/workspace/schemas/user_contribution.ex b/lib/algora/workspace/schemas/user_contribution.ex new file mode 100644 index 000000000..c4aa62c6c --- /dev/null +++ b/lib/algora/workspace/schemas/user_contribution.ex @@ -0,0 +1,33 @@ +defmodule Algora.Workspace.UserContribution do + @moduledoc """ + Schema for tracking user contributions to repositories. + """ + use Algora.Schema + + alias Algora.Accounts.User + alias Algora.Workspace.Repository + alias Algora.Workspace.UserContribution + + typed_schema "user_contributions" do + field :contribution_count, :integer, null: false, default: 0 + field :last_fetched_at, :utc_datetime_usec, null: false + + belongs_to :user, User, null: false + belongs_to :repository, Repository, null: false + + timestamps() + end + + @doc """ + Changeset for creating or updating a user contribution. + """ + def changeset(%UserContribution{} = contribution, attrs) do + contribution + |> cast(attrs, [:user_id, :repository_id, :contribution_count, :last_fetched_at]) + |> validate_required([:user_id, :repository_id, :contribution_count, :last_fetched_at]) + |> generate_id() + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:repository_id) + |> unique_constraint([:user_id, :repository_id]) + end +end diff --git a/lib/algora/workspace/workspace.ex b/lib/algora/workspace/workspace.ex index 995761018..5ec873ce0 100644 --- a/lib/algora/workspace/workspace.ex +++ b/lib/algora/workspace/workspace.ex @@ -15,6 +15,7 @@ defmodule Algora.Workspace do alias Algora.Workspace.Jobs alias Algora.Workspace.Repository alias Algora.Workspace.Ticket + alias Algora.Workspace.UserContribution require Logger @@ -130,21 +131,13 @@ defmodule Algora.Workspace do preload: [user: u] ) - res = - case Repo.one(repository_query) do - %Repository{} = repository -> {:ok, repository} - nil -> create_repository_from_github(token, owner, repo) - end - - case res do - {:ok, repository} -> maybe_schedule_og_image_update(repository) - error -> error + case Repo.one(repository_query) do + %Repository{} = repository -> {:ok, repository} + nil -> create_repository_from_github(token, owner, repo) end - - res end - defp maybe_schedule_og_image_update(%Repository{} = repository) do + def maybe_schedule_og_image_update(%Repository{} = repository) do one_day_ago = DateTime.add(DateTime.utc_now(), -1, :day) needs_update? = @@ -615,4 +608,117 @@ defmodule Algora.Workspace do :error end end + + @spec list_user_contributions(list(String.t()), Keyword.t()) :: {:ok, list(map())} | {:error, term()} + def list_user_contributions(ids, opts \\ []) do + query = + from uc in UserContribution, + join: u in assoc(uc, :user), + join: r in assoc(uc, :repository), + join: repo_owner in assoc(r, :user), + where: u.id in ^ids, + where: not ilike(r.name, "%awesome%"), + where: not ilike(r.name, "%algorithms%"), + where: not ilike(repo_owner.provider_login, "%algorithms%"), + where: repo_owner.type == :organization or r.stargazers_count > 200, + # where: fragment("? && ?::citext[]", r.tech_stack, ^(opts[:tech_stack] || [])), + order_by: [ + desc: fragment("CASE WHEN ? && ?::citext[] THEN 1 ELSE 0 END", r.tech_stack, ^(opts[:tech_stack] || [])), + desc: r.stargazers_count + ], + select_merge: %{user: u, repository: %{r | user: repo_owner}} + + query = + case opts[:limit] do + nil -> query + limit -> limit(query, ^limit) + end + + Repo.all(query) + end + + @spec upsert_user_contribution(String.t(), String.t(), integer()) :: + {:ok, UserContribution.t()} | {:error, Ecto.Changeset.t()} + def upsert_user_contribution(user_id, repository_id, contribution_count) do + attrs = %{ + user_id: user_id, + repository_id: repository_id, + contribution_count: contribution_count, + last_fetched_at: DateTime.utc_now() + } + + case Repo.get_by(UserContribution, user_id: user_id, repository_id: repository_id) do + nil -> %UserContribution{} |> UserContribution.changeset(attrs) |> Repo.insert() + contribution -> contribution |> UserContribution.changeset(attrs) |> Repo.update() + end + end + + def fetch_top_contributions(token, provider_login) do + with {:ok, contributions} <- Algora.Cloud.top_contributions(provider_login), + {:ok, user} <- ensure_user(token, provider_login), + :ok <- add_contributions(token, user.id, contributions) do + {:ok, contributions} + else + {:error, reason} -> + Logger.error("Failed to fetch contributions for #{provider_login}: #{inspect(reason)}") + {:error, reason} + end + end + + def fetch_top_contributions_async(token, provider_login) do + with {:ok, contributions} <- Algora.Cloud.top_contributions(provider_login), + {:ok, user} <- ensure_user(token, provider_login), + {:ok, _} <- add_contributions_async(token, user.id, contributions) do + {:ok, nil} + else + {:error, reason} -> + Logger.error("Failed to fetch contributions for #{provider_login}: #{inspect(reason)}") + {:error, reason} + end + end + + def add_contributions_async(_token, user_id, opts) do + Repo.transact(fn -> + Enum.reduce_while(opts, :ok, fn contribution, _ -> + case %{ + "user_id" => user_id, + "repo_full_name" => contribution.repo_name, + "contribution_count" => contribution.contribution_count + } + |> Jobs.SyncContribution.new() + |> Oban.insert() do + {:ok, _job} -> {:cont, :ok} + error -> {:halt, error} + end + end) + end) + end + + def add_contributions(token, user_id, opts) do + results = + Enum.map(opts, fn %{repo_name: repo_name, contribution_count: contribution_count} -> + add_contribution(%{ + token: token, + user_id: user_id, + repo_full_name: repo_name, + contribution_count: contribution_count + }) + end) + + if Enum.any?(results, fn result -> result == :ok end), do: :ok, else: {:error, :failed} + end + + def add_contribution(%{ + token: token, + user_id: user_id, + repo_full_name: repo_full_name, + contribution_count: contribution_count + }) do + with [repo_owner, repo_name] <- String.split(repo_full_name, "/"), + {:ok, repo} <- ensure_repository(token, repo_owner, repo_name), + {:ok, _tech_stack} <- ensure_repo_tech_stack(token, repo), + {:ok, _contribution} <- upsert_user_contribution(user_id, repo.id, contribution_count) do + :ok + end + end end diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index 3538dd97c..1bb5ba3f3 100644 --- a/lib/algora_web/components/core_components.ex +++ b/lib/algora_web/components/core_components.ex @@ -1238,11 +1238,15 @@ defmodule AlgoraWeb.CoreComponents do attr :link, :string, default: nil attr :class, :string, default: nil slot :inner_block + slot :actions def section(assigns) do ~H"""
-
+

{@title}

{@subtitle}

@@ -1250,6 +1254,9 @@ defmodule AlgoraWeb.CoreComponents do <.button :if={@link} navigate={@link} variant="outline"> View all +
+ {render_slot(@actions)} +
{render_slot(@inner_block)}
diff --git a/lib/algora_web/components/footer.ex b/lib/algora_web/components/footer.ex index 712a6efb0..080ac336c 100644 --- a/lib/algora_web/components/footer.ex +++ b/lib/algora_web/components/footer.ex @@ -13,7 +13,7 @@ defmodule AlgoraWeb.Components.Footer do