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