From 99cb955e8f94303ca1032818fe6ca846703bcb6e Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 4 May 2025 18:04:52 +0300 Subject: [PATCH 01/13] feat: add admin page to seed entries --- lib/algora/application.ex | 1 + lib/algora/psp/connect_countries.ex | 194 +++++++++++++ lib/algora/settings/settings.ex | 1 + lib/algora_web/live/admin/seed_live.ex | 387 +++++++++++++++++++++++++ lib/algora_web/router.ex | 1 + 5 files changed, 584 insertions(+) create mode 100644 lib/algora_web/live/admin/seed_live.ex diff --git a/lib/algora/application.ex b/lib/algora/application.ex index bbd40471a..991d3dde9 100644 --- a/lib/algora/application.ex +++ b/lib/algora/application.ex @@ -38,6 +38,7 @@ defmodule Algora.Application do # Start the ETS tables AlgoraWeb.Admin.CampaignLive.start_link() + AlgoraWeb.Admin.SeedLive.start_link() # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options diff --git a/lib/algora/psp/connect_countries.ex b/lib/algora/psp/connect_countries.ex index d5f7eac0b..39b60bf9f 100644 --- a/lib/algora/psp/connect_countries.ex +++ b/lib/algora/psp/connect_countries.ex @@ -139,4 +139,198 @@ defmodule Algora.PSP.ConnectCountries do @spec account_type(String.t()) :: :standard | :express def account_type("BR"), do: :standard def account_type(_), do: :express + + @spec regions() :: %{atom() => [String.t()]} + def regions do + %{ + "LATAM" => [ + "AR", + "BO", + "BR", + "CL", + "CO", + "CR", + "DO", + "EC", + "GT", + "GY", + "MX", + "PA", + "PE", + "PY", + "SV", + "TT", + "UY" + ], + "APAC" => [ + "AU", + "BD", + "BN", + "BT", + "HK", + "ID", + "IN", + "JP", + "KH", + "KR", + "LA", + "LK", + "MO", + "MM", + "MN", + "MY", + "NZ", + "PH", + "PK", + "SG", + "TH", + "TW", + "VN" + ], + "EMEA" => [ + "AE", + "AL", + "AM", + "AO", + "AT", + "AZ", + "BA", + "BE", + "BG", + "BH", + "BJ", + "BW", + "CH", + "CI", + "CY", + "CZ", + "DE", + "DK", + "DZ", + "EE", + "EG", + "ES", + "ET", + "FI", + "FR", + "GA", + "GB", + "GH", + "GI", + "GM", + "GR", + "HR", + "HU", + "IE", + "IL", + "IS", + "IT", + "JO", + "KE", + "KW", + "KZ", + "LI", + "LT", + "LU", + "LV", + "MA", + "MC", + "MD", + "MG", + "MK", + "MT", + "MU", + "MZ", + "NA", + "NG", + "NL", + "NO", + "OM", + "PL", + "PT", + "QA", + "RO", + "RS", + "RW", + "SA", + "SE", + "SI", + "SK", + "SM", + "SN", + "TN", + "TR", + "TZ", + "ZA" + ], + "NA" => [ + "US", + "CA" + ], + "EU" => [ + "AE", + "AL", + "AM", + "AT", + "AZ", + "BA", + "BE", + "BG", + "BH", + "CH", + "CY", + "CZ", + "DE", + "DK", + "EE", + "EG", + "ES", + "FI", + "FR", + "GB", + "GI", + "GR", + "HR", + "HU", + "IE", + "IL", + "IS", + "IT", + "JO", + "KW", + "KZ", + "LI", + "LT", + "LU", + "LV", + "MA", + "MC", + "MD", + "MK", + "MT", + "NL", + "NO", + "OM", + "PL", + "PT", + "QA", + "RO", + "RS", + "SA", + "SE", + "SI", + "SK", + "SM", + "TN", + "TR" + ] + } + end + + def get_countries(region) do + case regions()[region] do + nil -> [] + countries -> countries + end + end end diff --git a/lib/algora/settings/settings.ex b/lib/algora/settings/settings.ex index f235dbc9b..143de34d2 100644 --- a/lib/algora/settings/settings.ex +++ b/lib/algora/settings/settings.ex @@ -6,6 +6,7 @@ defmodule Algora.Settings do alias Algora.Accounts alias Algora.Accounts.User + alias Algora.PSP.ConnectCountries alias Algora.Repo @primary_key {:key, :string, []} diff --git a/lib/algora_web/live/admin/seed_live.ex b/lib/algora_web/live/admin/seed_live.ex new file mode 100644 index 000000000..2c3452704 --- /dev/null +++ b/lib/algora_web/live/admin/seed_live.ex @@ -0,0 +1,387 @@ +defmodule AlgoraWeb.Admin.SeedLive do + @moduledoc false + + use AlgoraWeb, :live_view + + import Ecto.Changeset + + alias Algora.Accounts.User + alias Algora.Jobs.JobPosting + alias Algora.Organizations + alias Algora.Repo + alias Algora.Workspace + alias AlgoraWeb.LocalStore + + require Logger + + @user_cache_table :seed_user_cache + + def start_link do + :ets.new(@user_cache_table, [:named_table, :set, :public]) + end + + defmodule Form do + @moduledoc false + use Ecto.Schema + + embedded_schema do + field :csv, :string + field :visible_columns, {:array, :string}, default: [] + end + + def changeset(campaign, attrs \\ %{}) do + campaign + |> cast(attrs, [:csv, :visible_columns]) + |> validate_required([:csv]) + |> validate_length(:csv, min: 1) + end + end + + @impl true + def mount(_params, _session, socket) do + timezone = if(params = get_connect_params(socket), do: params["timezone"]) + + {:ok, + socket + |> assign(:timezone, timezone) + |> assign(:page_title, "Seed") + |> assign(:form, to_form(Form.changeset(%Form{}, %{visible_columns: []}))) + |> assign_preview()} + end + + @impl true + def handle_params(_params, _uri, socket) do + {:noreply, + socket + |> LocalStore.init(key: __MODULE__, ttl: :infinity) + |> LocalStore.subscribe()} + end + + @impl true + def handle_event("restore_settings", params, socket) do + {:noreply, + socket + |> LocalStore.restore(params) + |> assign_preview()} + end + + @impl true + def handle_event("preview", %{"form" => params}, socket) do + {:noreply, + socket + |> LocalStore.assign_cached(:form, to_form(Form.changeset(%Form{}, params))) + |> assign_preview()} + end + + @impl true + def handle_event("seed", _params, socket) do + case seed_rows(socket.assigns.csv_data) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Jobs created successfully") + |> assign_preview()} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Failed to create jobs: #{inspect(reason)}")} + end + end + + @impl true + def render(assigns) do + ~H""" +
+
+ <.header> + Seed jobs + <:subtitle>Seed entries in database + + + <.form for={@form} phx-change="preview" class="space-y-4"> + <.input + type="textarea" + field={@form[:csv]} + label="CSV Data" + helptext="Enter your CSV data with headers matching the template variables" + /> + +
+ +
+ +
+
+
+ <.button type="button" phx-click="seed"> + Seed {length(@csv_data)} entries + +
+ <.table id="csv-data" rows={@csv_data}> + <:col + :let={row} + :for={ + key <- + @csv_columns |> Enum.filter(&(&1 in (@form.params["visible_columns"] || []))) + } + label={key} + > + <.cell value={row[key]} timezone={@timezone} column={key} /> + + +
+
+ +
+
+ """ + end + + defp cell(%{value: %User{}} = assigns) do + ~H""" +
+
+ <.avatar class="h-12 w-12"> + <.avatar_image src={@value.avatar_url} alt={@value.name} /> + <.avatar_fallback> + {Algora.Util.initials(@value.name)} + + +
+ +
+
+

{@value.name}

+ <%= for {platform, icon} <- social_links(), + url = social_link(@value, platform), + not is_nil(url) do %> + <.link href={url} target="_blank" class="text-muted-foreground hover:text-foreground"> + <.icon name={icon} class="size-5" /> + + <% end %> +
+

+ {@value.bio} +

+
+
+ """ + end + + defp cell(%{value: value} = assigns) when is_list(value) do + ~H""" +
+ <%= for item <- @value do %> + <.badge>{item} + <% end %> +
+ """ + end + + defp cell(%{value: %DateTime{}} = assigns) do + ~H""" + + {Calendar.strftime( + DateTime.from_naive!(@value, "Etc/UTC") |> DateTime.shift_zone!(@timezone), + "%Y/%m/%d, %H:%M:%S" + )} + + """ + end + + defp cell(%{value: "http" <> _value} = assigns) do + ~H""" + <.link href={@value} target="_blank" class="text-foreground"> + {@value} + + """ + end + + defp cell(assigns) do + ~H""" + + {@value} + + """ + end + + defp assign_csv_data(socket, data) do + all_rows = + case String.trim(data) do + "" -> [] + csv_string -> [csv_string] |> CSV.decode!() |> Enum.to_list() + end + + {cols, rows} = + case all_rows do + [] -> + {[], []} + + [cols | rows] -> + {cols, rows} + end + + rows = + rows + |> Stream.map(fn row -> Enum.zip_reduce(cols, row, Map.new(), fn col, val, acc -> Map.put(acc, col, val) end) end) + |> Stream.map(&add_user/1) + |> Stream.map(&process_row/1) + |> Enum.to_list() + + socket + |> assign(:csv_data, rows) + |> assign(:csv_columns, ["org" | cols]) + end + + defp assign_preview(socket) do + assign_csv_data( + socket, + case apply_action(socket.assigns.form.source, :save) do + {:ok, data} -> data.csv + {:error, _changeset} -> "" + end + ) + end + + defp add_user(row) do + key = row["org_handle"] + + user = + if not is_nil(key) do + case :ets.lookup(@user_cache_table, key) do + [{_, user}] -> + user + + _ -> + with {:ok, user} <- Workspace.ensure_user(Algora.Admin.token(), key), + {:ok, user} <- Repo.fetch(User, user.id) do + :ets.insert(@user_cache_table, {key, user}) + user + else + _ -> nil + end + end + end + + Map.put(row, "org", user) + end + + defp list_from_string(s) when is_binary(s) and s != "" do + s + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp list_from_string(_s), do: [] + + defp process_row(row) do + Map.merge(row, %{ + "tech_stack" => + cond do + row["tech_stack"] != "" -> list_from_string(row["tech_stack"]) + row["org"] -> Enum.take(row["org"].tech_stack, 1) + true -> [] + end, + "countries" => list_from_string(row["countries"]), + "regions" => list_from_string(row["regions"]), + "price" => row["price"] |> Decimal.new() |> Money.new!(:USD) + }) + end + + defp seed_rows(rows) do + Repo.transact( + fn -> + rows + |> Enum.map(&seed_row/1) + |> Enum.reduce_while(:ok, fn result, _acc -> + case result do + {:ok, _job} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end, + timeout: :infinity + ) + end + + defp to_domain(nil), do: nil + + defp to_domain(url) do + url + |> String.trim_leading("https://") + |> String.trim_leading("http://") + |> String.trim_leading("www.") + end + + defp seed_row(row) do + with {:ok, org} <- Repo.fetch(User, row["org"].id), + {:ok, org} <- + org + |> change( + Map.merge( + %{ + domain: org.domain || to_domain(row["website_url"]), + hiring_subscription: :trial, + subscription_price: row["price"], + billing_name: org.billing_name || row["billing_name"], + billing_address: org.billing_address || row["billing_address"], + executive_name: org.executive_name || row["executive_name"], + executive_role: org.executive_role || row["executive_role"] + }, + if org.handle do + %{} + else + %{handle: Organizations.ensure_unique_org_handle(row["org_handle"])} + end + ) + ) + |> Repo.update(), + {:ok, job} <- + Repo.insert(%JobPosting{ + status: :processing, + id: Nanoid.generate(), + user_id: org.id, + company_name: org.name, + company_url: org.website_url, + title: row["title"], + url: row["url"], + description: row["description"], + tech_stack: row["tech_stack"], + location: row["location"], + compensation: row["compensation"], + seniority: row["seniority"] + }) do + if row["countries"] != [], do: Algora.Settings.set_job_criteria(job.id, %{"countries" => row["countries"]}) + if row["regions"] != [], do: Algora.Settings.set_job_criteria(job.id, %{"regions" => row["regions"]}) + + {:ok, job} + end + end + + defp social_links do + [ + {:website, "tabler-world"}, + {:github, "github"}, + {:twitter, "tabler-brand-x"}, + {:youtube, "tabler-brand-youtube"}, + {:twitch, "tabler-brand-twitch"}, + {:discord, "tabler-brand-discord"}, + {:slack, "tabler-brand-slack"}, + {:linkedin, "tabler-brand-linkedin"} + ] + end + + defp social_link(user, :github), do: if(login = user.provider_login, do: "https://github.com/#{login}") + defp social_link(user, platform), do: Map.get(user, :"#{platform}_url") +end diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 7885992b5..80d2e30a7 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -54,6 +54,7 @@ defmodule AlgoraWeb.Router do live "/leaderboard", Admin.LeaderboardLive live "/chat/:id", Chat.ThreadLive live "/campaign", Admin.CampaignLive + live "/seed", Admin.SeedLive end live_dashboard "/dashboard", From c11c68aafd85d5be1f1ba1d463ed146cb67d63fd Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 4 May 2025 18:58:05 +0300 Subject: [PATCH 02/13] add countries and regions to job postings --- lib/algora/jobs/schemas/job_posting.ex | 6 ++- lib/algora/settings/settings.ex | 17 +++++++-- lib/algora_web/live/admin/seed_live.ex | 37 +++++++++---------- ..._countries_and_regions_to_job_postings.exs | 10 +++++ 4 files changed, 45 insertions(+), 25 deletions(-) create mode 100644 priv/repo/migrations/20250504155137_add_countries_and_regions_to_job_postings.exs diff --git a/lib/algora/jobs/schemas/job_posting.ex b/lib/algora/jobs/schemas/job_posting.ex index 5bc3f39a8..b8dae4db5 100644 --- a/lib/algora/jobs/schemas/job_posting.ex +++ b/lib/algora/jobs/schemas/job_posting.ex @@ -15,6 +15,8 @@ defmodule Algora.Jobs.JobPosting do field :status, Ecto.Enum, values: [:initialized, :processing, :active, :expired], null: false, default: :initialized field :expires_at, :utc_datetime_usec field :location, :string + field :countries, {:array, :string}, default: [] + field :regions, {:array, :string}, default: [] field :compensation, :string field :seniority, :string @@ -38,7 +40,9 @@ defmodule Algora.Jobs.JobPosting do :user_id, :location, :compensation, - :seniority + :seniority, + :countries, + :regions ]) |> generate_id() |> validate_required([:url, :company_name, :company_url, :email]) diff --git a/lib/algora/settings/settings.ex b/lib/algora/settings/settings.ex index 143de34d2..6f2e25d7f 100644 --- a/lib/algora/settings/settings.ex +++ b/lib/algora/settings/settings.ex @@ -119,10 +119,19 @@ defmodule Algora.Settings do set("job_criteria:#{job_id}", %{"criteria" => criteria}) end - def get_job_criteria(job_id) do - case get("job_criteria:#{job_id}") do - %{"criteria" => criteria} when is_map(criteria) -> criteria - _ -> %{} + def get_job_criteria(job) do + cond do + job.countries != [] -> + %{"countries" => job.countries} + + job.regions != [] -> + %{"regions" => job.regions} + + true -> + case get("job_criteria:#{job.id}") do + %{"criteria" => criteria} when is_map(criteria) -> criteria + _ -> %{} + end end end diff --git a/lib/algora_web/live/admin/seed_live.ex b/lib/algora_web/live/admin/seed_live.ex index 2c3452704..9c51de2d7 100644 --- a/lib/algora_web/live/admin/seed_live.ex +++ b/lib/algora_web/live/admin/seed_live.ex @@ -346,26 +346,23 @@ defmodule AlgoraWeb.Admin.SeedLive do end ) ) - |> Repo.update(), - {:ok, job} <- - Repo.insert(%JobPosting{ - status: :processing, - id: Nanoid.generate(), - user_id: org.id, - company_name: org.name, - company_url: org.website_url, - title: row["title"], - url: row["url"], - description: row["description"], - tech_stack: row["tech_stack"], - location: row["location"], - compensation: row["compensation"], - seniority: row["seniority"] - }) do - if row["countries"] != [], do: Algora.Settings.set_job_criteria(job.id, %{"countries" => row["countries"]}) - if row["regions"] != [], do: Algora.Settings.set_job_criteria(job.id, %{"regions" => row["regions"]}) - - {:ok, job} + |> Repo.update() do + Repo.insert(%JobPosting{ + status: :processing, + id: Nanoid.generate(), + user_id: org.id, + company_name: org.name, + company_url: org.website_url, + title: row["title"], + url: row["url"], + description: row["description"], + tech_stack: row["tech_stack"], + location: row["location"], + compensation: row["compensation"], + seniority: row["seniority"], + countries: row["countries"], + regions: row["regions"] + }) end end diff --git a/priv/repo/migrations/20250504155137_add_countries_and_regions_to_job_postings.exs b/priv/repo/migrations/20250504155137_add_countries_and_regions_to_job_postings.exs new file mode 100644 index 000000000..24a868686 --- /dev/null +++ b/priv/repo/migrations/20250504155137_add_countries_and_regions_to_job_postings.exs @@ -0,0 +1,10 @@ +defmodule Algora.Repo.Migrations.AddCountriesAndRegionsToJobPostings do + use Ecto.Migration + + def change do + alter table(:job_postings) do + add :countries, {:array, :string}, default: [], null: false + add :regions, {:array, :string}, default: [], null: false + end + end +end From c563cfdceaee7068434c2198f242b587351b1ef6 Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 4 May 2025 20:56:25 +0300 Subject: [PATCH 03/13] add hn page --- lib/algora/jobs/schemas/job_posting.ex | 3 + lib/algora/organizations/organizations.ex | 76 ++ lib/algora/settings/settings.ex | 22 + lib/algora_web/live/admin/seed_live.ex | 82 +- lib/algora_web/live/hn_jobs_live.ex | 962 ++++++++++++++++++++++ lib/algora_web/router.ex | 2 + 6 files changed, 1130 insertions(+), 17 deletions(-) create mode 100644 lib/algora_web/live/hn_jobs_live.ex diff --git a/lib/algora/jobs/schemas/job_posting.ex b/lib/algora/jobs/schemas/job_posting.ex index b8dae4db5..8bbbfc940 100644 --- a/lib/algora/jobs/schemas/job_posting.ex +++ b/lib/algora/jobs/schemas/job_posting.ex @@ -14,8 +14,11 @@ defmodule Algora.Jobs.JobPosting do field :email, :string field :status, Ecto.Enum, values: [:initialized, :processing, :active, :expired], null: false, default: :initialized field :expires_at, :utc_datetime_usec + # e.g. "SF Bay Area (Remote)" field :location, :string + # e.g. ["US", "CA", "BR"] field :countries, {:array, :string}, default: [] + # e.g. ["LATAM", "NA"] field :regions, {:array, :string}, default: [] field :compensation, :string field :seniority, :string diff --git a/lib/algora/organizations/organizations.ex b/lib/algora/organizations/organizations.ex index 619cd1fcd..953318a17 100644 --- a/lib/algora/organizations/organizations.ex +++ b/lib/algora/organizations/organizations.ex @@ -104,6 +104,82 @@ defmodule Algora.Organizations do end) end + def onboard_organization_from_domain(domain, opts \\ %{}) do + case Algora.Crawler.fetch_site_metadata(domain) do + {:ok, metadata} -> + org_name = + case get_in(metadata, [:display_name]) do + nil -> + domain + |> String.split(".") + |> List.first() + |> String.capitalize() + + name -> + name + end + + org_handle = + case get_in(metadata, [:handle]) do + nil -> + domain + |> String.split(".") + |> List.first() + |> String.downcase() + + handle -> + handle + end + + params = + Map.merge( + %{ + display_name: org_name, + bio: + get_in(metadata, [:bio]) || get_in(metadata, [:og_description]) || + get_in(metadata, [:og_title]), + avatar_url: get_in(metadata, [:avatar_url]) || get_in(metadata, [:favicon_url]), + handle: org_handle, + domain: domain, + og_title: get_in(metadata, [:og_title]), + og_image_url: get_in(metadata, [:og_image_url]), + website_url: get_in(metadata, [:website_url]), + twitter_url: get_in(metadata, [:socials, :twitter]), + github_url: get_in(metadata, [:socials, :github]), + youtube_url: get_in(metadata, [:socials, :youtube]), + twitch_url: get_in(metadata, [:socials, :twitch]), + discord_url: get_in(metadata, [:socials, :discord]), + slack_url: get_in(metadata, [:socials, :slack]), + linkedin_url: get_in(metadata, [:socials, :linkedin]) + }, + opts + ) + + org = Repo.one(from o in User, where: o.domain == ^domain, limit: 1) + + org_handle = + case org do + nil -> ensure_unique_org_handle(params.handle) + org -> org.handle + end + + case org do + nil -> + %User{type: :organization} + |> Org.changeset(Map.put(params, :handle, org_handle)) + |> Repo.insert() + + existing_org -> + existing_org + |> Org.changeset(Map.delete(params, :handle)) + |> Repo.update() + end + + {:error, error} -> + {:error, error} + end + end + def generate_handle_from_email(email) do email |> String.split("@") diff --git a/lib/algora/settings/settings.ex b/lib/algora/settings/settings.ex index 6f2e25d7f..021180bc5 100644 --- a/lib/algora/settings/settings.ex +++ b/lib/algora/settings/settings.ex @@ -244,4 +244,26 @@ defmodule Algora.Settings do def set_subscription_price(price) do set("subscription", %{"price" => Algora.MoneyUtils.serialize(price)}) end + + def get_hn_job_ids do + case get("hn_job_ids") do + %{"ids" => ids} when is_list(ids) -> ids + _ -> nil + end + end + + def set_hn_job_ids(ids) when is_list(ids) do + set("hn_job_ids", %{"ids" => ids}) + end + + def get_hn_job_seekers do + case get("hn_job_seekers") do + %{"handles" => handles} when is_list(handles) -> handles + _ -> nil + end + end + + def set_hn_job_seekers(handles) when is_list(handles) do + set("hn_job_seekers", %{"handles" => handles}) + end end diff --git a/lib/algora_web/live/admin/seed_live.ex b/lib/algora_web/live/admin/seed_live.ex index 9c51de2d7..2b5d42e8f 100644 --- a/lib/algora_web/live/admin/seed_live.ex +++ b/lib/algora_web/live/admin/seed_live.ex @@ -4,6 +4,7 @@ defmodule AlgoraWeb.Admin.SeedLive do use AlgoraWeb, :live_view import Ecto.Changeset + import Ecto.Query alias Algora.Accounts.User alias Algora.Jobs.JobPosting @@ -234,8 +235,9 @@ defmodule AlgoraWeb.Admin.SeedLive do rows = rows |> Stream.map(fn row -> Enum.zip_reduce(cols, row, Map.new(), fn col, val, acc -> Map.put(acc, col, val) end) end) - |> Stream.map(&add_user/1) |> Stream.map(&process_row/1) + |> Task.async_stream(&add_user/1, max_concurrency: 10, timeout: :infinity) + |> Stream.map(fn {:ok, row} -> row end) |> Enum.to_list() socket @@ -253,29 +255,66 @@ defmodule AlgoraWeb.Admin.SeedLive do ) end - defp add_user(row) do - key = row["org_handle"] - + defp add_user(%{"org_handle" => key} = row) when is_binary(key) and key != "" do user = - if not is_nil(key) do - case :ets.lookup(@user_cache_table, key) do - [{_, user}] -> + case :ets.lookup(@user_cache_table, key) do + [{_, user}] -> + user + + _ -> + with {:ok, user} <- Workspace.ensure_user(Algora.Admin.token(), key), + {:ok, user} <- Repo.fetch(User, user.id) do + :ets.insert(@user_cache_table, {key, user}) user + else + _ -> nil + end + end - _ -> - with {:ok, user} <- Workspace.ensure_user(Algora.Admin.token(), key), - {:ok, user} <- Repo.fetch(User, user.id) do - :ets.insert(@user_cache_table, {key, user}) - user - else - _ -> nil - end - end + Map.put(row, "org", user) + end + + defp add_user(%{"company_url" => url} = row) when is_binary(url) and url != "" do + user = + case :ets.lookup(@user_cache_table, url) do + [{_, user}] -> + user + + _ -> + domain = + url + |> String.trim_leading("https://") + |> String.trim_leading("http://") + |> String.trim_leading("www.") + + with {:ok, user} <- + fetch_or_create_user(domain, %{hiring: true, tech_stack: row["tech_stack"]}), + {:ok, user} <- Repo.fetch(User, user.id) do + :ets.insert(@user_cache_table, {url, user}) + user + else + _ -> + :ets.insert(@user_cache_table, {url, nil}) + nil + end end Map.put(row, "org", user) end + defp add_user(row), do: row + + def fetch_or_create_user(domain, opts) do + case Repo.one(from o in User, where: o.domain == ^domain, limit: 1) do + %User{} = user -> + {:ok, user} + + _ -> + res = Organizations.onboard_organization_from_domain(domain, opts) + res + end + end + defp list_from_string(s) when is_binary(s) and s != "" do s |> String.split(",") @@ -285,6 +324,14 @@ defmodule AlgoraWeb.Admin.SeedLive do defp list_from_string(_s), do: [] + defp money_from_string(s) when is_binary(s) and s != "" do + s + |> Decimal.new() + |> Money.new!(:USD) + end + + defp money_from_string(_s), do: nil + defp process_row(row) do Map.merge(row, %{ "tech_stack" => @@ -295,7 +342,7 @@ defmodule AlgoraWeb.Admin.SeedLive do end, "countries" => list_from_string(row["countries"]), "regions" => list_from_string(row["regions"]), - "price" => row["price"] |> Decimal.new() |> Money.new!(:USD) + "price" => money_from_string(row["price"]) }) end @@ -303,6 +350,7 @@ defmodule AlgoraWeb.Admin.SeedLive do Repo.transact( fn -> rows + |> Enum.filter(& &1["org"]) |> Enum.map(&seed_row/1) |> Enum.reduce_while(:ok, fn result, _acc -> case result do diff --git a/lib/algora_web/live/hn_jobs_live.ex b/lib/algora_web/live/hn_jobs_live.ex new file mode 100644 index 000000000..9a6360063 --- /dev/null +++ b/lib/algora_web/live/hn_jobs_live.ex @@ -0,0 +1,962 @@ +defmodule AlgoraWeb.HNJobsLive do + @moduledoc false + use AlgoraWeb, :live_view + + import Ecto.Query + + alias Algora.Accounts.User + alias Algora.Jobs + alias Algora.Jobs.JobPosting + alias Algora.Repo + alias Algora.Settings + alias AlgoraWeb.Forms.BountyForm + alias AlgoraWeb.Forms.ContractForm + alias AlgoraWeb.Forms.TipForm + + require Logger + + defp default_tab, do: "jobs" + + @impl true + def mount(_params, _session, socket) do + jobs = + JobPosting + |> where([j], j.id in ^Settings.get_hn_job_ids()) + |> order_by([j], desc: j.inserted_at) + |> Repo.all() + |> Repo.preload(:user) + + jobs_by_user = Enum.group_by(jobs, & &1.user) + + {:ok, + socket + |> assign(:page_title, "HN Who's Hiring") + |> assign(:show_share_drawer, false) + |> 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(:share_drawer_type, nil) + |> assign(:selected_developer, nil) + |> assign(:jobs, jobs) + |> assign(:jobs_by_user, jobs_by_user) + |> assign_developers() + |> assign_user_applications()} + end + + @impl true + def handle_params(params, _uri, socket) do + {:noreply, assign(socket, :current_tab, params["tab"] || default_tab())} + end + + @impl true + def render(assigns) do + ~H""" +
+ <.section> + <.card class="flex flex-col p-6"> +
+
+
+ <.avatar class="h-16 w-16"> + <.avatar_image src="https://news.ycombinator.com/y18.svg" /> + +
+
+ HN Who's Hiring +
+
+ Browse and search the HN Who's Hiring Database +
+
+
+
+
+ + + +
+
+ <%= for {tab, label, count} <- [ + {"jobs", "Jobs", length(@jobs)}, + {"developers", "Developers", length(@job_seekers)}, + {"matches", "Matches", length(@matches)} + ] do %> + + <% end %> +
+ <%= case @current_tab do %> + <% "jobs" -> %> + <.section title="Jobs" subtitle="All job postings"> + <:actions> + <.button variant="default" phx-click="toggle_import_drawer"> + Post your job + + + <%= if Enum.empty?(@jobs_by_user) do %> + <.card class="rounded-lg bg-card py-12 text-center lg:rounded-[2rem]"> + <.card_header> +
+ <.icon name="tabler-briefcase" class="h-8 w-8 text-muted-foreground" /> +
+ <.card_title>No jobs yet + <.card_description> + Open positions will appear here once created + + + + <% else %> +
+ <%= for {user, jobs} <- @jobs_by_user do %> + <.card class="flex flex-col p-6"> +
+ <.avatar class="h-16 w-16"> + <.avatar_image src={user.avatar_url} /> + <.avatar_fallback> + {Algora.Util.initials(user.name)} + + +
+
+ {user.name} +
+
+ {user.bio} +
+
+ <%= for {platform, icon} <- social_icons(), + url = social_link(user, platform), + not is_nil(url) do %> + <.link + href={url} + target="_blank" + class="text-muted-foreground hover:text-foreground" + > + <.icon name={icon} class="size-4" /> + + <% end %> +
+
+
+ +
+ <%= for job <- jobs do %> +
+
+
+
+ {job.title} +
+
+
+ {job.description} +
+
+ <%= for tech <- job.tech_stack do %> + <.badge variant="outline">{tech} + <% end %> +
+
+ <%= if MapSet.member?(@user_applications, job.id) do %> + <.button disabled class="opacity-50"> + <.icon name="tabler-check" class="h-4 w-4 mr-2 -ml-1" /> Applied + + <% else %> + <.button phx-click="apply_job" phx-value-job-id={job.id}> + <.icon name="github" class="h-4 w-4 mr-2" /> Apply with GitHub + + <% end %> +
+ <% end %> +
+ + <% end %> +
+ <% end %> + + <% "developers" -> %> + <.section title="Developers" subtitle="All job seekers"> + <:actions> + <.button variant="default" phx-click="toggle_import_drawer"> + Sign up + + + <%= if Enum.empty?(@job_seekers) do %> + <.card class="rounded-lg bg-card py-12 text-center lg:rounded-[2rem]"> + <.card_header> +
+ <.icon name="tabler-users" class="h-8 w-8 text-muted-foreground" /> +
+ <.card_title>No job seekers yet + <.card_description> + Job seekers will appear here once created + + + + <% else %> +
+ <%= for job_seeker <- @job_seekers do %> +
+ <.developer_card + user={job_seeker} + contributions={Map.get(@contributions_map, job_seeker.id, [])} + contract_type="bring_your_own" + /> +
+ <% end %> +
+ <% end %> + + <% "matches" -> %> + <.section title="Matches" subtitle="Top developers matching your requirements"> + <%= if Enum.empty?(@matches) do %> + <.card class="rounded-lg bg-card py-12 text-center lg:rounded-[2rem]"> + <.card_header> +
+ <.icon name="tabler-users" class="h-8 w-8 text-muted-foreground" /> +
+ <.card_title>No matches yet + <.card_description> + Matches will appear here once we find developers matching your requirements + + + + <% else %> +
+ <%= for match <- @matches |> Enum.take(if @current_org.hiring_subscription == :active, do: length(@matches), else: 3) do %> +
+ <.match_card + user={match.user} + tech_stack={[]} + contributions={Map.get(@contributions_map, match.user.id, [])} + contract_type="bring_your_own" + /> +
+ <% end %> + <%= if @current_org.hiring_subscription != :active do %> +
+ +
+ + {length(@matches) - 3} more matches +
+
+ <% end %> +
+ <% end %> + + <% end %> +
+
+ + {share_drawer(assigns)} + """ + end + + @impl true + def handle_event("change_tab", %{"tab" => tab}, socket) do + {:noreply, push_patch(socket, to: "/hn/#{tab}")} + end + + @impl true + def handle_event("apply_job", %{"job-id" => job_id}, socket) do + if socket.assigns[:current_user] do + if Accounts.has_fresh_token?(socket.assigns.current_user) do + case Jobs.create_application(job_id, socket.assigns.current_user) do + {:ok, _application} -> + {:noreply, assign_user_applications(socket)} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to submit application. Please try again.")} + end + else + {:noreply, + socket + |> push_event("store-session", %{user_return_to: "/jobs"}) + |> redirect(external: Algora.Github.authorize_url())} + end + else + {:noreply, + socket + |> push_event("store-session", %{user_return_to: "/jobs"}) + |> redirect(external: Algora.Github.authorize_url())} + end + end + + @impl true + def handle_event( + "share_opportunity", + %{"user_id" => user_id, "type" => "contract", "contract_type" => contract_type}, + socket + ) do + developer = Enum.find(socket.assigns.developers, &(&1.id == user_id)) + match = Enum.find(socket.assigns.matches, &(&1.user.id == user_id)) + hourly_rate = match[:hourly_rate] + + hours_per_week = developer.hours_per_week || 30 + + {:noreply, + socket + |> assign(:main_contract_form_open?, true) + |> assign( + :main_contract_form, + %ContractForm{ + contract_type: String.to_existing_atom(contract_type), + contractor: match[:user] || developer + } + |> ContractForm.changeset(%{ + amount: if(hourly_rate, do: Money.mult!(hourly_rate, hours_per_week)), + hourly_rate: hourly_rate, + contractor_handle: developer.provider_login, + hours_per_week: hours_per_week, + title: "#{socket.assigns.current_org.name} OSS Development", + description: "Open source contribution to #{socket.assigns.current_org.name} for a week" + }) + |> to_form() + )} + end + + @impl true + 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(:share_drawer_type, type) + |> assign(:show_share_drawer, true)} + end + + @impl true + def handle_event("close_share_drawer", _params, socket) do + {:noreply, assign(socket, :show_share_drawer, false)} + end + + @impl true + def handle_event(_event, _params, socket) do + {:noreply, socket} + end + + defp assign_developers(socket) do + job_seekers = Repo.all(from u in User, where: u.provider_login in ^Settings.get_hn_job_seekers()) + matches = [] + + developers = Enum.concat(matches, job_seekers) + + contributions_map = fetch_applicants_contributions(developers, []) + + socket + |> assign(:developers, developers) + |> assign(:job_seekers, sort_by_contributions(job_seekers, contributions_map)) + |> assign(:matches, sort_by_contributions(matches, contributions_map)) + |> assign(:contributions_map, contributions_map) + end + + defp assign_user_applications(socket) do + user_applications = + if socket.assigns[:current_user] do + Jobs.list_user_applications(socket.assigns.current_user) + else + MapSet.new() + end + + assign(socket, :user_applications, user_applications) + end + + defp developer_card(assigns) do + ~H""" +
+
+
+
+ <.link navigate={User.url(@user)}> + <.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)} + + + + +
+
+ <.link navigate={User.url(@user)} class="font-semibold hover:underline"> + {@user.name} + + {Algora.Misc.CountryEmojis.get(@user.country)} + + +
+
+ <.link + :if={@user.provider_login} + href={"https://github.com/#{@user.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="github" class="shrink-0 h-4 w-4" /> + {@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="shrink-0 h-4 w-4" /> + + {@user.provider_meta["twitter_handle"]} + + +
+
+
+
+ +
+ <.button + phx-click="share_opportunity" + phx-value-user_id={@user.id} + phx-value-type="bounty" + variant="outline" + size="sm" + > + Bounty + + <.button + phx-click="share_opportunity" + phx-value-user_id={@user.id} + phx-value-type="tip" + variant="outline" + size="sm" + > + Interview + + <.button + phx-click="share_opportunity" + phx-value-user_id={@user.id} + phx-value-type="contract" + phx-value-contract_type={@contract_type} + variant="outline" + size="sm" + > + Contract + +
+ +
+

+ Top contributions +

+
+ <%= if @contributions == [] do %> + <%= for _ <- 1..3 do %> +
+ <% end %> + <% else %> + <%= for {owner, contributions} <- aggregate_contributions(@contributions) |> Enum.take(3) do %> + <.link + href={"https://github.com/#{owner.provider_login}/#{List.first(contributions).repository.name}/pulls?q=author%3A#{@user.provider_login}+is%3Amerged+"} + target="_blank" + rel="noopener" + class="flex items-center gap-3 rounded-xl pr-2 bg-card/50 border border-border/50 hover:border-border transition-all" + > + {owner.name} +
+ + + {if owner.type == :organization do + owner.name + else + List.first(contributions).repository.name + end} + + <%= if tech = List.first(List.first(contributions).repository.tech_stack) do %> + + {tech} + + <% end %> + +
+ + <.icon name="tabler-star-filled" class="h-4 w-4 mr-1" /> + {Algora.Util.format_number_compact( + max(owner.stargazers_count, total_stars(contributions)) + )} + + + <.icon name="tabler-git-pull-request" class="h-4 w-4 mr-1" /> + {Algora.Util.format_number_compact(total_contributions(contributions))} + +
+
+ + <% end %> + <% end %> +
+
+
+
+ """ + end + + defp match_card(assigns) do + ~H""" +
+
+
+
+ <.link navigate={User.url(@user)}> + <.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)} + + + + +
+
+ <.link navigate={User.url(@user)} class="font-semibold hover:underline"> + {@user.name} + + {Algora.Misc.CountryEmojis.get(@user.country)} + + +
+
+ <.link + :if={@user.provider_login} + href={"https://github.com/#{@user.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="github" class="shrink-0 h-4 w-4" /> + {@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="shrink-0 h-4 w-4" /> + + {@user.provider_meta["twitter_handle"]} + + +
+
+
+
+ +
+ <.button + phx-click="share_opportunity" + phx-value-user_id={@user.id} + phx-value-type="bounty" + variant="outline" + size="sm" + > + Bounty + + <.button + phx-click="share_opportunity" + phx-value-user_id={@user.id} + phx-value-type="tip" + variant="outline" + size="sm" + > + Interview + + <.button + phx-click="share_opportunity" + phx-value-user_id={@user.id} + phx-value-type="contract" + phx-value-contract_type={@contract_type} + variant="outline" + size="sm" + > + Contract + +
+ +
+

+ Top contributions +

+
+ <%= for {owner, contributions} <- aggregate_contributions(@contributions) |> Enum.take(3) do %> + <.link + href={"https://github.com/#{owner.provider_login}/#{List.first(contributions).repository.name}/pulls?q=author%3A#{@user.provider_login}+is%3Amerged+"} + target="_blank" + rel="noopener" + class="flex items-center gap-3 rounded-xl pr-2 bg-card/50 border border-border/50 hover:border-border transition-all" + > + {owner.name} +
+ + + {if owner.type == :organization do + owner.name + else + List.first(contributions).repository.name + end} + + <%= if tech = List.first(List.first(contributions).repository.tech_stack) do %> + + {tech} + + <% end %> + +
+ + <.icon name="tabler-star-filled" class="h-4 w-4 mr-1" /> + {Algora.Util.format_number_compact( + max(owner.stargazers_count, total_stars(contributions)) + )} + + + <.icon name="tabler-git-pull-request" class="h-4 w-4 mr-1" /> + {Algora.Util.format_number_compact(total_contributions(contributions))} + +
+
+ + <% end %> +
+
+
+
+ """ + end + + # Fetch contributions for all applicants and create a map for quick lookup + defp fetch_applicants_contributions(users, tech_stack) do + users + |> Enum.map(& &1.id) + |> Algora.Workspace.list_user_contributions(tech_stack: tech_stack) + |> Enum.group_by(& &1.user.id) + end + + defp sort_by_contributions(applicants, contributions_map) do + Enum.sort_by(applicants, &Algora.Cloud.get_contribution_score([], &1, contributions_map), :desc) + end + + defp aggregate_contributions(contributions) do + groups = Enum.group_by(contributions, fn c -> c.repository.user end) + + contributions + |> Enum.map(fn c -> {c.repository.user, groups[c.repository.user]} end) + |> Enum.uniq_by(fn {owner, _} -> owner.id end) + end + + defp total_stars(contributions) do + contributions + |> Enum.map(& &1.repository.stargazers_count) + |> Enum.sum() + end + + defp total_contributions(contributions) do + contributions + |> Enum.map(& &1.contribution_count) + |> Enum.sum() + end + + defp share_drawer_header(%{share_drawer_type: "contract"} = assigns) do + ~H""" + <.drawer_header> + <.drawer_title>Offer Contract + <.drawer_description> + {@selected_developer.name} will be notified and can accept or decline. You can auto-renew or cancel the contract at the end of each period. + + + """ + end + + 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 + + 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""" + <.form for={@contract_form} phx-submit="create_contract"> + <.card> + <.card_header> + <.card_title>Contract Details + + <.card_content class="pt-0 flex flex-col"> +
+ <.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""" + <.form for={@bounty_form} phx-submit="create_bounty"> + <.card> + <.card_header> + <.card_title>Bounty Details + + <.card_content class="pt-0 flex flex-col"> +
+ <.input type="hidden" name="bounty_form[visibility]" value="exclusive" /> + <.input + type="hidden" + name="bounty_form[shared_with][]" + value={ + case @selected_developer do + %{handle: nil, provider_id: provider_id} -> [to_string(provider_id)] + %{id: id} -> [id] + end + } + /> + <.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""" + <.form for={@tip_form} phx-submit="create_tip"> + <.card> + <.card_header> + <.card_title>Tip Details + + <.card_content class="pt-0 flex flex-col"> +
+ + <.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 + + defp share_drawer_developer_info(assigns) do + ~H""" + <.card> + <.card_header> + <.card_title>Developer + + <.card_content class="pt-0"> +
+ <.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} + + {Algora.Misc.CountryEmojis.get(@selected_developer.country)} + +
+ +
+ <.link + :if={@selected_developer.provider_login} + href={"https://github.com/#{@selected_developer.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="github" class="h-4 w-4" /> + {@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 %> +
+
+
+ + + """ + end + + defp share_drawer(assigns) do + ~H""" + <.drawer show={@show_share_drawer} direction="bottom" 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"> +
+ <.share_drawer_developer_info selected_developer={@selected_developer} /> + <.share_drawer_content + :if={@selected_developer} + selected_developer={@selected_developer} + share_drawer_type={@share_drawer_type} + bounty_form={@bounty_form} + tip_form={@tip_form} + contract_form={@contract_form} + /> +
+ + + """ + end + + defp social_icons do + %{ + website: "tabler-world", + github: "github", + twitter: "tabler-brand-x", + youtube: "tabler-brand-youtube", + twitch: "tabler-brand-twitch", + discord: "tabler-brand-discord", + slack: "tabler-brand-slack", + linkedin: "tabler-brand-linkedin" + } + end + + defp social_link(user, :github), do: if(login = user.provider_login, do: "https://github.com/#{login}") + defp social_link(user, platform), do: Map.get(user, :"#{platform}_url") +end diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 80d2e30a7..d7d72c74c 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -125,6 +125,8 @@ defmodule AlgoraWeb.Router do live "/community", CommunityLive, :index live "/community/:tech", CommunityLive, :index live "/crowdfund", CrowdfundLive, :index + live "/hn", HNJobsLive, :index + live "/hn/:tab", HNJobsLive, :index live "/pricing", PricingLive live "/challenges", ChallengesLive live "/challenges/prettier", Challenges.PrettierLive From 641d44af7d7468defd0d731d7c5d1c1493805995 Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 4 May 2025 23:11:45 +0300 Subject: [PATCH 04/13] add llm --- lib/algora/admin/admin.ex | 148 ++++++++++++++++++ lib/algora/organizations/organizations.ex | 2 + lib/algora_web/live/hn_jobs_live.ex | 2 +- mix.exs | 1 + mix.lock | 3 + ...4200111_update_job_posting_description.exs | 26 +++ 6 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 priv/repo/migrations/20250504200111_update_job_posting_description.exs diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index 7bb16ed0e..176190a29 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -24,6 +24,154 @@ defmodule Algora.Admin do require Logger + defmodule JobPostingPrediction do + @moduledoc false + use Ecto.Schema + use Instructor.Validator + + alias Algora.Organizations + + @llm_doc """ + ## Field Descriptions: + - tech_stack: List of technologies used in the job posting (e.g. ["Ruby", "Rails", "PostgreSQL"]) + - countries: List of 2-letter ISO country codes (e.g. ["US", "CA"]) + - regions: List of regions (e.g. ["EMEA", "LATAM"]) + - location: Location of the job posting (e.g. "Remote", "San Francisco, CA", "London/Berlin") + - seniority: Seniority level (e.g. "Senior", "Mid-Senior", "Entry-Level") + - company_url: Company website URL which can be derived from email (e.g. example.com) + """ + @primary_key false + embedded_schema do + embeds_many :job_postings, JobPosting, primary_key: false do + field(:title, :string) + field(:description, :string) + field(:tech_stack, {:array, :string}) + field(:company_name, :string) + field(:company_url, :string) + field(:location, :string) + field(:countries, {:array, :string}) + field(:regions, {:array, :string}) + field(:compensation, :string) + field(:seniority, :string) + end + end + + @impl true + def validate_changeset(changeset) do + changeset + end + + def seed_jobs(jobs) do + jobs + |> Task.async_stream(&seed/1, timeout: :infinity, max_concurrency: 50) + |> Enum.to_list() + end + + def seed(job) do + with domain when not is_nil(domain) <- to_domain(job.company_url), + {:ok, org} <- fetch_or_create_user(domain, %{hiring: true, tech_stack: job.tech_stack}), + {:ok, org} <- + org + |> change( + Map.merge( + %{ + domain: org.domain || domain, + hiring_subscription: :trial, + billing_name: org.billing_name || job.company_name, + billing_address: org.billing_address || job.location, + executive_name: org.executive_name || job.company_name, + executive_role: org.executive_role || job.seniority + }, + if org.handle do + %{} + else + %{handle: Organizations.ensure_unique_org_handle(job.company_name)} + end + ) + ) + |> Repo.update() do + Repo.insert(%JobPosting{ + status: :processing, + id: Nanoid.generate(), + user_id: org.id, + company_name: org.name, + company_url: org.website_url, + title: job.title, + description: job.description, + tech_stack: job.tech_stack, + location: job.location, + compensation: job.compensation, + seniority: job.seniority, + countries: job.countries, + regions: job.regions + }) + end + end + + defp to_domain(nil), do: nil + + defp to_domain(url) do + url + |> String.trim_leading("https://") + |> String.trim_leading("http://") + |> String.trim_leading("www.") + end + + def fetch_or_create_user(domain, opts) do + case Repo.one(from o in User, where: o.domain == ^domain, limit: 1) do + %User{} = user -> + {:ok, user} + + _ -> + res = Organizations.onboard_organization_from_domain(domain, opts) + res + end + end + end + + def classify_jobs(jobs) do + batches = Enum.chunk_every(jobs, 10) + + batches + |> Enum.with_index() + |> Enum.flat_map(fn {jobs, index} -> + case classify_batch(jobs, index) do + {:ok, predictions} -> + predictions.job_postings + + {:error, error} -> + Logger.error("Failed to classify batch #{index}: #{inspect(error)}") + [] + end + end) + end + + def classify_batch(jobs, index) do + text = Enum.join(jobs, "\n\n") + + Github.Client.run_cached("classify_jobs_#{index}", fn -> + Instructor.chat_completion( + model: "gpt-4o-mini", + response_model: JobPostingPrediction, + max_retries: 2, + messages: [ + %{ + role: "user", + content: """ + Your purpose is to turn arbitrary job postings into structured data. + + Return a distinct entry for each job. Some paragraphs may contain multiple jobs. + + Turn following job postings into structured data: + + #{text} + """ + } + ] + ) + end) + end + def seed_job(opts \\ %{}) do with {:ok, user} <- Repo.fetch_by(User, handle: opts.org.handle), {:ok, user} <- user |> change(opts.org) |> Repo.update(), diff --git a/lib/algora/organizations/organizations.ex b/lib/algora/organizations/organizations.ex index 953318a17..b75ed9e85 100644 --- a/lib/algora/organizations/organizations.ex +++ b/lib/algora/organizations/organizations.ex @@ -163,6 +163,8 @@ defmodule Algora.Organizations do org -> org.handle end + dbg(params) + case org do nil -> %User{type: :organization} diff --git a/lib/algora_web/live/hn_jobs_live.ex b/lib/algora_web/live/hn_jobs_live.ex index 9a6360063..d4b9bbd54 100644 --- a/lib/algora_web/live/hn_jobs_live.ex +++ b/lib/algora_web/live/hn_jobs_live.ex @@ -21,7 +21,7 @@ defmodule AlgoraWeb.HNJobsLive do def mount(_params, _session, socket) do jobs = JobPosting - |> where([j], j.id in ^Settings.get_hn_job_ids()) + # |> where([j], j.id in ^Settings.get_hn_job_ids()) |> order_by([j], desc: j.inserted_at) |> Repo.all() |> Repo.preload(:user) diff --git a/mix.exs b/mix.exs index bb799aefe..4491ebebe 100644 --- a/mix.exs +++ b/mix.exs @@ -99,6 +99,7 @@ defmodule Algora.MixProject do {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}, {:cmark, "~> 0.10"}, {:csv, "~> 3.2"}, + {:instructor, "~> 0.1.0"}, # ex_aws {:ex_aws, "~> 2.1"}, {:ex_aws_s3, "~> 2.0"}, diff --git a/mix.lock b/mix.lock index bef3db3a6..ebe6ed13c 100644 --- a/mix.lock +++ b/mix.lock @@ -46,7 +46,9 @@ "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "instructor": {:hex, :instructor, "0.1.0", "ca587fa11b9de7dff68b6f0a28ee17682d35f67efa20f71aef61bbb528444562", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jaxon, "~> 2.0", [hex: :jaxon, repo: "hexpm", optional: false]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "05a7020a460ca43dc1123c7903e928b678360cd37eac761f472bd8e0787fefcb"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jaxon": {:hex, :jaxon, "2.0.8", "00951a79d354260e28d7e36f956c3de94818124768a4b22e0fc55559d1b3bfe7", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "74532853b1126609615ea98f0ceb5009e70465ca98027afbbd8ed314d887e82d"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "live_svelte": {:hex, :live_svelte, "0.14.1", "78fcd3bb7eb1c349138ebcaef5b61653bf1a818e42129c847482717895af8f70", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:nodejs, "~> 3.1", [hex: :nodejs, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 3.3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "f96b06456957fbc6d25f71fc984f8f949664d5b61fbfcb3db43d5b82699c39a8"}, @@ -82,6 +84,7 @@ "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "redirect": {:hex, :redirect, "0.4.0", "98b46053504ee517bc3ad2fd04c064b64b48d339e1e18266355b30c4f8bb52b0", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.3 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dfa29a8ecbad066ed0b73b34611cf24c78101719737f37bdf750f39197d67b97"}, + "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, "reverse_proxy_plug": {:hex, :reverse_proxy_plug, "3.0.2", "38fde2f59bca8b219ef4f1ec0c0849a67c6d9705160e426a2354f35399db5c7b", [:mix], [{:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.2 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.3.0 or ~> 0.4.0 or ~> 0.5.0", [hex: :req, repo: "hexpm", optional: true]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: true]}], "hexpm", "31ae5e068f7f504fba1b5c17c31c87966c720809ac15140c6c181440fbd24eda"}, "rustler": {:hex, :rustler, "0.36.1", "2d4b1ff57ea2789a44756a40dbb5fbb73c6ee0a13d031dcba96d0a5542598a6a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "f3fba4ad272970e0d1bc62972fc4a99809651e54a125c5242de9bad4574b2d02"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.2", "5f25cbe220a8fac3e7ad62e6f950fcdca5a5a5f8501835d2823e8c74bf4268d5", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "63d1bd5f8e23096d1ff851839923162096364bac8656a4a3c00d1fff8e83ee0a"}, diff --git a/priv/repo/migrations/20250504200111_update_job_posting_description.exs b/priv/repo/migrations/20250504200111_update_job_posting_description.exs new file mode 100644 index 000000000..8c11e80e1 --- /dev/null +++ b/priv/repo/migrations/20250504200111_update_job_posting_description.exs @@ -0,0 +1,26 @@ +defmodule Algora.Repo.Migrations.UpdateJobPostingDescription do + use Ecto.Migration + + def up do + alter table(:job_postings) do + modify :description, :text + modify :company_name, :text + modify :company_url, :text + end + + alter table(:users) do + modify :linkedin_url, :text + modify :website_url, :text + modify :discord_url, :text + modify :github_url, :text + modify :twitter_url, :text + modify :youtube_url, :text + modify :slack_url, :text + modify :twitch_url, :text + modify :og_image_url, :text + end + end + + def down do + end +end From 427c47f53664d5e288b639e603ad2d38736f76a8 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 5 May 2025 12:12:26 +0300 Subject: [PATCH 05/13] refactor --- lib/algora_web/live/admin/seed_live.ex | 81 ++++++++++++-------------- 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/lib/algora_web/live/admin/seed_live.ex b/lib/algora_web/live/admin/seed_live.ex index 2b5d42e8f..979c24a4a 100644 --- a/lib/algora_web/live/admin/seed_live.ex +++ b/lib/algora_web/live/admin/seed_live.ex @@ -236,7 +236,7 @@ defmodule AlgoraWeb.Admin.SeedLive do rows |> Stream.map(fn row -> Enum.zip_reduce(cols, row, Map.new(), fn col, val, acc -> Map.put(acc, col, val) end) end) |> Stream.map(&process_row/1) - |> Task.async_stream(&add_user/1, max_concurrency: 10, timeout: :infinity) + |> Task.async_stream(&Map.put(&1, "org", get_user(&1)), max_concurrency: 10, timeout: :infinity) |> Stream.map(fn {:ok, row} -> row end) |> Enum.to_list() @@ -255,54 +255,49 @@ defmodule AlgoraWeb.Admin.SeedLive do ) end - defp add_user(%{"org_handle" => key} = row) when is_binary(key) and key != "" do - user = - case :ets.lookup(@user_cache_table, key) do - [{_, user}] -> - user - - _ -> - with {:ok, user} <- Workspace.ensure_user(Algora.Admin.token(), key), - {:ok, user} <- Repo.fetch(User, user.id) do - :ets.insert(@user_cache_table, {key, user}) - user - else - _ -> nil - end - end + defp get_user(%{"org_handle" => handle} = _row) when is_binary(handle) and handle != "" do + case :ets.lookup(@user_cache_table, handle) do + [{_, user}] -> + user - Map.put(row, "org", user) - end - - defp add_user(%{"company_url" => url} = row) when is_binary(url) and url != "" do - user = - case :ets.lookup(@user_cache_table, url) do - [{_, user}] -> + _ -> + with {:ok, user} <- Workspace.ensure_user(Algora.Admin.token(), handle), + {:ok, user} <- Repo.fetch(User, user.id) do + :ets.insert(@user_cache_table, {handle, user}) user + else + _ -> + :ets.insert(@user_cache_table, {handle, nil}) + nil + end + end + end - _ -> - domain = - url - |> String.trim_leading("https://") - |> String.trim_leading("http://") - |> String.trim_leading("www.") - - with {:ok, user} <- - fetch_or_create_user(domain, %{hiring: true, tech_stack: row["tech_stack"]}), - {:ok, user} <- Repo.fetch(User, user.id) do - :ets.insert(@user_cache_table, {url, user}) - user - else - _ -> - :ets.insert(@user_cache_table, {url, nil}) - nil - end - end + defp get_user(%{"company_url" => url} = row) when is_binary(url) and url != "" do + case :ets.lookup(@user_cache_table, url) do + [{_, user}] -> + user - Map.put(row, "org", user) + _ -> + domain = + url + |> String.trim_leading("https://") + |> String.trim_leading("http://") + |> String.trim_leading("www.") + + with {:ok, user} <- fetch_or_create_user(domain, %{hiring: true, tech_stack: row["tech_stack"]}), + {:ok, user} <- Repo.fetch(User, user.id) do + :ets.insert(@user_cache_table, {url, user}) + user + else + _ -> + :ets.insert(@user_cache_table, {url, nil}) + nil + end + end end - defp add_user(row), do: row + defp get_user(_row), do: nil def fetch_or_create_user(domain, opts) do case Repo.one(from o in User, where: o.domain == ^domain, limit: 1) do From c334b6abf71cab1fb835778f9a02968726dd6fa9 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 5 May 2025 13:09:30 +0300 Subject: [PATCH 06/13] prevent duplicate concurrent tasks --- lib/algora_web/live/admin/seed_live.ex | 78 ++++++++++++++------------ 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/lib/algora_web/live/admin/seed_live.ex b/lib/algora_web/live/admin/seed_live.ex index 979c24a4a..990520765 100644 --- a/lib/algora_web/live/admin/seed_live.ex +++ b/lib/algora_web/live/admin/seed_live.ex @@ -171,8 +171,8 @@ defmodule AlgoraWeb.Admin.SeedLive do <% end %>
-

- {@value.bio} +

+ {@value.domain}

@@ -234,11 +234,14 @@ defmodule AlgoraWeb.Admin.SeedLive do rows = rows - |> Stream.map(fn row -> Enum.zip_reduce(cols, row, Map.new(), fn col, val, acc -> Map.put(acc, col, val) end) end) - |> Stream.map(&process_row/1) - |> Task.async_stream(&Map.put(&1, "org", get_user(&1)), max_concurrency: 10, timeout: :infinity) - |> Stream.map(fn {:ok, row} -> row end) - |> Enum.to_list() + |> Enum.map(fn row -> Enum.zip_reduce(cols, row, Map.new(), fn col, val, acc -> Map.put(acc, col, val) end) end) + |> Enum.map(&process_row/1) + + rows + |> Enum.uniq_by(&lookup_key/1) + |> Task.async_stream(&get_user/1, max_concurrency: 10, timeout: :infinity) + + rows = Enum.map(rows, &Map.put(&1, "org", get_user(&1))) socket |> assign(:csv_data, rows) @@ -255,46 +258,49 @@ defmodule AlgoraWeb.Admin.SeedLive do ) end - defp get_user(%{"org_handle" => handle} = _row) when is_binary(handle) and handle != "" do - case :ets.lookup(@user_cache_table, handle) do + defp lookup_key(%{"org_handle" => handle} = _row) when is_binary(handle) and handle != "" do + handle + end + + defp lookup_key(%{"company_url" => url} = _row) when is_binary(url) and url != "" do + to_domain(url) + end + + defp run_cached(key, fun) do + case :ets.lookup(@user_cache_table, key) do [{_, user}] -> user _ -> - with {:ok, user} <- Workspace.ensure_user(Algora.Admin.token(), handle), - {:ok, user} <- Repo.fetch(User, user.id) do - :ets.insert(@user_cache_table, {handle, user}) - user - else - _ -> - :ets.insert(@user_cache_table, {handle, nil}) + case fun.() do + {:ok, user} -> + :ets.insert(@user_cache_table, {key, user}) + user + + error -> + Logger.error("Failed to fetch user #{key}: #{inspect(error)}") + :ets.insert(@user_cache_table, {key, nil}) nil end end end + defp get_user(%{"org_handle" => handle} = _row) when is_binary(handle) and handle != "" do + run_cached(handle, fn -> + with {:ok, user} <- Workspace.ensure_user(Algora.Admin.token(), handle) do + Repo.fetch(User, user.id) + end + end) + end + defp get_user(%{"company_url" => url} = row) when is_binary(url) and url != "" do - case :ets.lookup(@user_cache_table, url) do - [{_, user}] -> - user + domain = to_domain(url) - _ -> - domain = - url - |> String.trim_leading("https://") - |> String.trim_leading("http://") - |> String.trim_leading("www.") - - with {:ok, user} <- fetch_or_create_user(domain, %{hiring: true, tech_stack: row["tech_stack"]}), - {:ok, user} <- Repo.fetch(User, user.id) do - :ets.insert(@user_cache_table, {url, user}) - user - else - _ -> - :ets.insert(@user_cache_table, {url, nil}) - nil - end - end + run_cached(domain, fn -> + with {:ok, user} <- fetch_or_create_user(domain, %{hiring: true, tech_stack: row["tech_stack"]}) do + Repo.fetch(User, user.id) + end + end) end defp get_user(_row), do: nil From fd579d78164054a6baf4af40b772837bfced9af7 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 5 May 2025 13:15:54 +0300 Subject: [PATCH 07/13] add missing field indicator --- lib/algora_web/live/admin/seed_live.ex | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/algora_web/live/admin/seed_live.ex b/lib/algora_web/live/admin/seed_live.ex index 990520765..fee8aea83 100644 --- a/lib/algora_web/live/admin/seed_live.ex +++ b/lib/algora_web/live/admin/seed_live.ex @@ -171,9 +171,15 @@ defmodule AlgoraWeb.Admin.SeedLive do <% end %> -

- {@value.domain} -

+ <%= if @value.domain do %> +

+ {@value.domain} +

+ <% else %> +

+ Domain not found +

+ <% end %> """ From aa7f62f4d4533dc07d5185e556bf26e2a84ec42e Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 5 May 2025 13:18:38 +0300 Subject: [PATCH 08/13] reuse code --- lib/algora/admin/admin.ex | 11 +---------- lib/algora_web/live/admin/seed_live.ex | 15 +++------------ 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index 176190a29..8deec43fd 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -68,7 +68,7 @@ defmodule Algora.Admin do end def seed(job) do - with domain when not is_nil(domain) <- to_domain(job.company_url), + with domain when not is_nil(domain) <- Util.to_domain(job.company_url), {:ok, org} <- fetch_or_create_user(domain, %{hiring: true, tech_stack: job.tech_stack}), {:ok, org} <- org @@ -108,15 +108,6 @@ defmodule Algora.Admin do end end - defp to_domain(nil), do: nil - - defp to_domain(url) do - url - |> String.trim_leading("https://") - |> String.trim_leading("http://") - |> String.trim_leading("www.") - end - def fetch_or_create_user(domain, opts) do case Repo.one(from o in User, where: o.domain == ^domain, limit: 1) do %User{} = user -> diff --git a/lib/algora_web/live/admin/seed_live.ex b/lib/algora_web/live/admin/seed_live.ex index fee8aea83..8813ef77b 100644 --- a/lib/algora_web/live/admin/seed_live.ex +++ b/lib/algora_web/live/admin/seed_live.ex @@ -269,7 +269,7 @@ defmodule AlgoraWeb.Admin.SeedLive do end defp lookup_key(%{"company_url" => url} = _row) when is_binary(url) and url != "" do - to_domain(url) + Algora.Util.to_domain(url) end defp run_cached(key, fun) do @@ -300,7 +300,7 @@ defmodule AlgoraWeb.Admin.SeedLive do end defp get_user(%{"company_url" => url} = row) when is_binary(url) and url != "" do - domain = to_domain(url) + domain = Algora.Util.to_domain(url) run_cached(domain, fn -> with {:ok, user} <- fetch_or_create_user(domain, %{hiring: true, tech_stack: row["tech_stack"]}) do @@ -370,15 +370,6 @@ defmodule AlgoraWeb.Admin.SeedLive do ) end - defp to_domain(nil), do: nil - - defp to_domain(url) do - url - |> String.trim_leading("https://") - |> String.trim_leading("http://") - |> String.trim_leading("www.") - end - defp seed_row(row) do with {:ok, org} <- Repo.fetch(User, row["org"].id), {:ok, org} <- @@ -386,7 +377,7 @@ defmodule AlgoraWeb.Admin.SeedLive do |> change( Map.merge( %{ - domain: org.domain || to_domain(row["website_url"]), + domain: org.domain || Algora.Util.to_domain(row["website_url"]), hiring_subscription: :trial, subscription_price: row["price"], billing_name: org.billing_name || row["billing_name"], From 3c19cf36474cf5ce10448aab026cc7ab26af749c Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 5 May 2025 17:26:41 +0300 Subject: [PATCH 09/13] fix apply with github redirect --- lib/algora_web/live/hn_jobs_live.ex | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/algora_web/live/hn_jobs_live.ex b/lib/algora_web/live/hn_jobs_live.ex index d4b9bbd54..62cadeb3d 100644 --- a/lib/algora_web/live/hn_jobs_live.ex +++ b/lib/algora_web/live/hn_jobs_live.ex @@ -291,16 +291,10 @@ defmodule AlgoraWeb.HNJobsLive do {:noreply, put_flash(socket, :error, "Failed to submit application. Please try again.")} end else - {:noreply, - socket - |> push_event("store-session", %{user_return_to: "/jobs"}) - |> redirect(external: Algora.Github.authorize_url())} + {:noreply, redirect(socket, external: Algora.Github.authorize_url(%{return_to: "/hn/jobs"}))} end else - {:noreply, - socket - |> push_event("store-session", %{user_return_to: "/jobs"}) - |> redirect(external: Algora.Github.authorize_url())} + {:noreply, redirect(socket, external: Algora.Github.authorize_url(%{return_to: "/hn/jobs"}))} end end From 82d9fe56ed98344eb3dfbda056c4a211b2b1af78 Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 6 May 2025 15:17:40 +0300 Subject: [PATCH 10/13] reorganize --- lib/algora/admin/admin.ex | 139 ---- lib/algora/settings/settings.ex | 22 - lib/algora_web/live/hn_jobs_live.ex | 956 ---------------------------- lib/algora_web/router.ex | 2 - mix.exs | 1 - mix.lock | 3 - 6 files changed, 1123 deletions(-) delete mode 100644 lib/algora_web/live/hn_jobs_live.ex diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index 8deec43fd..7bb16ed0e 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -24,145 +24,6 @@ defmodule Algora.Admin do require Logger - defmodule JobPostingPrediction do - @moduledoc false - use Ecto.Schema - use Instructor.Validator - - alias Algora.Organizations - - @llm_doc """ - ## Field Descriptions: - - tech_stack: List of technologies used in the job posting (e.g. ["Ruby", "Rails", "PostgreSQL"]) - - countries: List of 2-letter ISO country codes (e.g. ["US", "CA"]) - - regions: List of regions (e.g. ["EMEA", "LATAM"]) - - location: Location of the job posting (e.g. "Remote", "San Francisco, CA", "London/Berlin") - - seniority: Seniority level (e.g. "Senior", "Mid-Senior", "Entry-Level") - - company_url: Company website URL which can be derived from email (e.g. example.com) - """ - @primary_key false - embedded_schema do - embeds_many :job_postings, JobPosting, primary_key: false do - field(:title, :string) - field(:description, :string) - field(:tech_stack, {:array, :string}) - field(:company_name, :string) - field(:company_url, :string) - field(:location, :string) - field(:countries, {:array, :string}) - field(:regions, {:array, :string}) - field(:compensation, :string) - field(:seniority, :string) - end - end - - @impl true - def validate_changeset(changeset) do - changeset - end - - def seed_jobs(jobs) do - jobs - |> Task.async_stream(&seed/1, timeout: :infinity, max_concurrency: 50) - |> Enum.to_list() - end - - def seed(job) do - with domain when not is_nil(domain) <- Util.to_domain(job.company_url), - {:ok, org} <- fetch_or_create_user(domain, %{hiring: true, tech_stack: job.tech_stack}), - {:ok, org} <- - org - |> change( - Map.merge( - %{ - domain: org.domain || domain, - hiring_subscription: :trial, - billing_name: org.billing_name || job.company_name, - billing_address: org.billing_address || job.location, - executive_name: org.executive_name || job.company_name, - executive_role: org.executive_role || job.seniority - }, - if org.handle do - %{} - else - %{handle: Organizations.ensure_unique_org_handle(job.company_name)} - end - ) - ) - |> Repo.update() do - Repo.insert(%JobPosting{ - status: :processing, - id: Nanoid.generate(), - user_id: org.id, - company_name: org.name, - company_url: org.website_url, - title: job.title, - description: job.description, - tech_stack: job.tech_stack, - location: job.location, - compensation: job.compensation, - seniority: job.seniority, - countries: job.countries, - regions: job.regions - }) - end - end - - def fetch_or_create_user(domain, opts) do - case Repo.one(from o in User, where: o.domain == ^domain, limit: 1) do - %User{} = user -> - {:ok, user} - - _ -> - res = Organizations.onboard_organization_from_domain(domain, opts) - res - end - end - end - - def classify_jobs(jobs) do - batches = Enum.chunk_every(jobs, 10) - - batches - |> Enum.with_index() - |> Enum.flat_map(fn {jobs, index} -> - case classify_batch(jobs, index) do - {:ok, predictions} -> - predictions.job_postings - - {:error, error} -> - Logger.error("Failed to classify batch #{index}: #{inspect(error)}") - [] - end - end) - end - - def classify_batch(jobs, index) do - text = Enum.join(jobs, "\n\n") - - Github.Client.run_cached("classify_jobs_#{index}", fn -> - Instructor.chat_completion( - model: "gpt-4o-mini", - response_model: JobPostingPrediction, - max_retries: 2, - messages: [ - %{ - role: "user", - content: """ - Your purpose is to turn arbitrary job postings into structured data. - - Return a distinct entry for each job. Some paragraphs may contain multiple jobs. - - Turn following job postings into structured data: - - #{text} - """ - } - ] - ) - end) - end - def seed_job(opts \\ %{}) do with {:ok, user} <- Repo.fetch_by(User, handle: opts.org.handle), {:ok, user} <- user |> change(opts.org) |> Repo.update(), diff --git a/lib/algora/settings/settings.ex b/lib/algora/settings/settings.ex index 021180bc5..6f2e25d7f 100644 --- a/lib/algora/settings/settings.ex +++ b/lib/algora/settings/settings.ex @@ -244,26 +244,4 @@ defmodule Algora.Settings do def set_subscription_price(price) do set("subscription", %{"price" => Algora.MoneyUtils.serialize(price)}) end - - def get_hn_job_ids do - case get("hn_job_ids") do - %{"ids" => ids} when is_list(ids) -> ids - _ -> nil - end - end - - def set_hn_job_ids(ids) when is_list(ids) do - set("hn_job_ids", %{"ids" => ids}) - end - - def get_hn_job_seekers do - case get("hn_job_seekers") do - %{"handles" => handles} when is_list(handles) -> handles - _ -> nil - end - end - - def set_hn_job_seekers(handles) when is_list(handles) do - set("hn_job_seekers", %{"handles" => handles}) - end end diff --git a/lib/algora_web/live/hn_jobs_live.ex b/lib/algora_web/live/hn_jobs_live.ex deleted file mode 100644 index 62cadeb3d..000000000 --- a/lib/algora_web/live/hn_jobs_live.ex +++ /dev/null @@ -1,956 +0,0 @@ -defmodule AlgoraWeb.HNJobsLive do - @moduledoc false - use AlgoraWeb, :live_view - - import Ecto.Query - - alias Algora.Accounts.User - alias Algora.Jobs - alias Algora.Jobs.JobPosting - alias Algora.Repo - alias Algora.Settings - alias AlgoraWeb.Forms.BountyForm - alias AlgoraWeb.Forms.ContractForm - alias AlgoraWeb.Forms.TipForm - - require Logger - - defp default_tab, do: "jobs" - - @impl true - def mount(_params, _session, socket) do - jobs = - JobPosting - # |> where([j], j.id in ^Settings.get_hn_job_ids()) - |> order_by([j], desc: j.inserted_at) - |> Repo.all() - |> Repo.preload(:user) - - jobs_by_user = Enum.group_by(jobs, & &1.user) - - {:ok, - socket - |> assign(:page_title, "HN Who's Hiring") - |> assign(:show_share_drawer, false) - |> 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(:share_drawer_type, nil) - |> assign(:selected_developer, nil) - |> assign(:jobs, jobs) - |> assign(:jobs_by_user, jobs_by_user) - |> assign_developers() - |> assign_user_applications()} - end - - @impl true - def handle_params(params, _uri, socket) do - {:noreply, assign(socket, :current_tab, params["tab"] || default_tab())} - end - - @impl true - def render(assigns) do - ~H""" -
- <.section> - <.card class="flex flex-col p-6"> -
-
-
- <.avatar class="h-16 w-16"> - <.avatar_image src="https://news.ycombinator.com/y18.svg" /> - -
-
- HN Who's Hiring -
-
- Browse and search the HN Who's Hiring Database -
-
-
-
-
- - - -
-
- <%= for {tab, label, count} <- [ - {"jobs", "Jobs", length(@jobs)}, - {"developers", "Developers", length(@job_seekers)}, - {"matches", "Matches", length(@matches)} - ] do %> - - <% end %> -
- <%= case @current_tab do %> - <% "jobs" -> %> - <.section title="Jobs" subtitle="All job postings"> - <:actions> - <.button variant="default" phx-click="toggle_import_drawer"> - Post your job - - - <%= if Enum.empty?(@jobs_by_user) do %> - <.card class="rounded-lg bg-card py-12 text-center lg:rounded-[2rem]"> - <.card_header> -
- <.icon name="tabler-briefcase" class="h-8 w-8 text-muted-foreground" /> -
- <.card_title>No jobs yet - <.card_description> - Open positions will appear here once created - - - - <% else %> -
- <%= for {user, jobs} <- @jobs_by_user do %> - <.card class="flex flex-col p-6"> -
- <.avatar class="h-16 w-16"> - <.avatar_image src={user.avatar_url} /> - <.avatar_fallback> - {Algora.Util.initials(user.name)} - - -
-
- {user.name} -
-
- {user.bio} -
-
- <%= for {platform, icon} <- social_icons(), - url = social_link(user, platform), - not is_nil(url) do %> - <.link - href={url} - target="_blank" - class="text-muted-foreground hover:text-foreground" - > - <.icon name={icon} class="size-4" /> - - <% end %> -
-
-
- -
- <%= for job <- jobs do %> -
-
-
-
- {job.title} -
-
-
- {job.description} -
-
- <%= for tech <- job.tech_stack do %> - <.badge variant="outline">{tech} - <% end %> -
-
- <%= if MapSet.member?(@user_applications, job.id) do %> - <.button disabled class="opacity-50"> - <.icon name="tabler-check" class="h-4 w-4 mr-2 -ml-1" /> Applied - - <% else %> - <.button phx-click="apply_job" phx-value-job-id={job.id}> - <.icon name="github" class="h-4 w-4 mr-2" /> Apply with GitHub - - <% end %> -
- <% end %> -
- - <% end %> -
- <% end %> - - <% "developers" -> %> - <.section title="Developers" subtitle="All job seekers"> - <:actions> - <.button variant="default" phx-click="toggle_import_drawer"> - Sign up - - - <%= if Enum.empty?(@job_seekers) do %> - <.card class="rounded-lg bg-card py-12 text-center lg:rounded-[2rem]"> - <.card_header> -
- <.icon name="tabler-users" class="h-8 w-8 text-muted-foreground" /> -
- <.card_title>No job seekers yet - <.card_description> - Job seekers will appear here once created - - - - <% else %> -
- <%= for job_seeker <- @job_seekers do %> -
- <.developer_card - user={job_seeker} - contributions={Map.get(@contributions_map, job_seeker.id, [])} - contract_type="bring_your_own" - /> -
- <% end %> -
- <% end %> - - <% "matches" -> %> - <.section title="Matches" subtitle="Top developers matching your requirements"> - <%= if Enum.empty?(@matches) do %> - <.card class="rounded-lg bg-card py-12 text-center lg:rounded-[2rem]"> - <.card_header> -
- <.icon name="tabler-users" class="h-8 w-8 text-muted-foreground" /> -
- <.card_title>No matches yet - <.card_description> - Matches will appear here once we find developers matching your requirements - - - - <% else %> -
- <%= for match <- @matches |> Enum.take(if @current_org.hiring_subscription == :active, do: length(@matches), else: 3) do %> -
- <.match_card - user={match.user} - tech_stack={[]} - contributions={Map.get(@contributions_map, match.user.id, [])} - contract_type="bring_your_own" - /> -
- <% end %> - <%= if @current_org.hiring_subscription != :active do %> -
- -
- + {length(@matches) - 3} more matches -
-
- <% end %> -
- <% end %> - - <% end %> -
-
- - {share_drawer(assigns)} - """ - end - - @impl true - def handle_event("change_tab", %{"tab" => tab}, socket) do - {:noreply, push_patch(socket, to: "/hn/#{tab}")} - end - - @impl true - def handle_event("apply_job", %{"job-id" => job_id}, socket) do - if socket.assigns[:current_user] do - if Accounts.has_fresh_token?(socket.assigns.current_user) do - case Jobs.create_application(job_id, socket.assigns.current_user) do - {:ok, _application} -> - {:noreply, assign_user_applications(socket)} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, "Failed to submit application. Please try again.")} - end - else - {:noreply, redirect(socket, external: Algora.Github.authorize_url(%{return_to: "/hn/jobs"}))} - end - else - {:noreply, redirect(socket, external: Algora.Github.authorize_url(%{return_to: "/hn/jobs"}))} - end - end - - @impl true - def handle_event( - "share_opportunity", - %{"user_id" => user_id, "type" => "contract", "contract_type" => contract_type}, - socket - ) do - developer = Enum.find(socket.assigns.developers, &(&1.id == user_id)) - match = Enum.find(socket.assigns.matches, &(&1.user.id == user_id)) - hourly_rate = match[:hourly_rate] - - hours_per_week = developer.hours_per_week || 30 - - {:noreply, - socket - |> assign(:main_contract_form_open?, true) - |> assign( - :main_contract_form, - %ContractForm{ - contract_type: String.to_existing_atom(contract_type), - contractor: match[:user] || developer - } - |> ContractForm.changeset(%{ - amount: if(hourly_rate, do: Money.mult!(hourly_rate, hours_per_week)), - hourly_rate: hourly_rate, - contractor_handle: developer.provider_login, - hours_per_week: hours_per_week, - title: "#{socket.assigns.current_org.name} OSS Development", - description: "Open source contribution to #{socket.assigns.current_org.name} for a week" - }) - |> to_form() - )} - end - - @impl true - 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(:share_drawer_type, type) - |> assign(:show_share_drawer, true)} - end - - @impl true - def handle_event("close_share_drawer", _params, socket) do - {:noreply, assign(socket, :show_share_drawer, false)} - end - - @impl true - def handle_event(_event, _params, socket) do - {:noreply, socket} - end - - defp assign_developers(socket) do - job_seekers = Repo.all(from u in User, where: u.provider_login in ^Settings.get_hn_job_seekers()) - matches = [] - - developers = Enum.concat(matches, job_seekers) - - contributions_map = fetch_applicants_contributions(developers, []) - - socket - |> assign(:developers, developers) - |> assign(:job_seekers, sort_by_contributions(job_seekers, contributions_map)) - |> assign(:matches, sort_by_contributions(matches, contributions_map)) - |> assign(:contributions_map, contributions_map) - end - - defp assign_user_applications(socket) do - user_applications = - if socket.assigns[:current_user] do - Jobs.list_user_applications(socket.assigns.current_user) - else - MapSet.new() - end - - assign(socket, :user_applications, user_applications) - end - - defp developer_card(assigns) do - ~H""" -
-
-
-
- <.link navigate={User.url(@user)}> - <.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)} - - - - -
-
- <.link navigate={User.url(@user)} class="font-semibold hover:underline"> - {@user.name} - - {Algora.Misc.CountryEmojis.get(@user.country)} - - -
-
- <.link - :if={@user.provider_login} - href={"https://github.com/#{@user.provider_login}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - <.icon name="github" class="shrink-0 h-4 w-4" /> - {@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="shrink-0 h-4 w-4" /> - - {@user.provider_meta["twitter_handle"]} - - -
-
-
-
- -
- <.button - phx-click="share_opportunity" - phx-value-user_id={@user.id} - phx-value-type="bounty" - variant="outline" - size="sm" - > - Bounty - - <.button - phx-click="share_opportunity" - phx-value-user_id={@user.id} - phx-value-type="tip" - variant="outline" - size="sm" - > - Interview - - <.button - phx-click="share_opportunity" - phx-value-user_id={@user.id} - phx-value-type="contract" - phx-value-contract_type={@contract_type} - variant="outline" - size="sm" - > - Contract - -
- -
-

- Top contributions -

-
- <%= if @contributions == [] do %> - <%= for _ <- 1..3 do %> -
- <% end %> - <% else %> - <%= for {owner, contributions} <- aggregate_contributions(@contributions) |> Enum.take(3) do %> - <.link - href={"https://github.com/#{owner.provider_login}/#{List.first(contributions).repository.name}/pulls?q=author%3A#{@user.provider_login}+is%3Amerged+"} - target="_blank" - rel="noopener" - class="flex items-center gap-3 rounded-xl pr-2 bg-card/50 border border-border/50 hover:border-border transition-all" - > - {owner.name} -
- - - {if owner.type == :organization do - owner.name - else - List.first(contributions).repository.name - end} - - <%= if tech = List.first(List.first(contributions).repository.tech_stack) do %> - - {tech} - - <% end %> - -
- - <.icon name="tabler-star-filled" class="h-4 w-4 mr-1" /> - {Algora.Util.format_number_compact( - max(owner.stargazers_count, total_stars(contributions)) - )} - - - <.icon name="tabler-git-pull-request" class="h-4 w-4 mr-1" /> - {Algora.Util.format_number_compact(total_contributions(contributions))} - -
-
- - <% end %> - <% end %> -
-
-
-
- """ - end - - defp match_card(assigns) do - ~H""" -
-
-
-
- <.link navigate={User.url(@user)}> - <.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)} - - - - -
-
- <.link navigate={User.url(@user)} class="font-semibold hover:underline"> - {@user.name} - - {Algora.Misc.CountryEmojis.get(@user.country)} - - -
-
- <.link - :if={@user.provider_login} - href={"https://github.com/#{@user.provider_login}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - <.icon name="github" class="shrink-0 h-4 w-4" /> - {@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="shrink-0 h-4 w-4" /> - - {@user.provider_meta["twitter_handle"]} - - -
-
-
-
- -
- <.button - phx-click="share_opportunity" - phx-value-user_id={@user.id} - phx-value-type="bounty" - variant="outline" - size="sm" - > - Bounty - - <.button - phx-click="share_opportunity" - phx-value-user_id={@user.id} - phx-value-type="tip" - variant="outline" - size="sm" - > - Interview - - <.button - phx-click="share_opportunity" - phx-value-user_id={@user.id} - phx-value-type="contract" - phx-value-contract_type={@contract_type} - variant="outline" - size="sm" - > - Contract - -
- -
-

- Top contributions -

-
- <%= for {owner, contributions} <- aggregate_contributions(@contributions) |> Enum.take(3) do %> - <.link - href={"https://github.com/#{owner.provider_login}/#{List.first(contributions).repository.name}/pulls?q=author%3A#{@user.provider_login}+is%3Amerged+"} - target="_blank" - rel="noopener" - class="flex items-center gap-3 rounded-xl pr-2 bg-card/50 border border-border/50 hover:border-border transition-all" - > - {owner.name} -
- - - {if owner.type == :organization do - owner.name - else - List.first(contributions).repository.name - end} - - <%= if tech = List.first(List.first(contributions).repository.tech_stack) do %> - - {tech} - - <% end %> - -
- - <.icon name="tabler-star-filled" class="h-4 w-4 mr-1" /> - {Algora.Util.format_number_compact( - max(owner.stargazers_count, total_stars(contributions)) - )} - - - <.icon name="tabler-git-pull-request" class="h-4 w-4 mr-1" /> - {Algora.Util.format_number_compact(total_contributions(contributions))} - -
-
- - <% end %> -
-
-
-
- """ - end - - # Fetch contributions for all applicants and create a map for quick lookup - defp fetch_applicants_contributions(users, tech_stack) do - users - |> Enum.map(& &1.id) - |> Algora.Workspace.list_user_contributions(tech_stack: tech_stack) - |> Enum.group_by(& &1.user.id) - end - - defp sort_by_contributions(applicants, contributions_map) do - Enum.sort_by(applicants, &Algora.Cloud.get_contribution_score([], &1, contributions_map), :desc) - end - - defp aggregate_contributions(contributions) do - groups = Enum.group_by(contributions, fn c -> c.repository.user end) - - contributions - |> Enum.map(fn c -> {c.repository.user, groups[c.repository.user]} end) - |> Enum.uniq_by(fn {owner, _} -> owner.id end) - end - - defp total_stars(contributions) do - contributions - |> Enum.map(& &1.repository.stargazers_count) - |> Enum.sum() - end - - defp total_contributions(contributions) do - contributions - |> Enum.map(& &1.contribution_count) - |> Enum.sum() - end - - defp share_drawer_header(%{share_drawer_type: "contract"} = assigns) do - ~H""" - <.drawer_header> - <.drawer_title>Offer Contract - <.drawer_description> - {@selected_developer.name} will be notified and can accept or decline. You can auto-renew or cancel the contract at the end of each period. - - - """ - end - - 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 - - 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""" - <.form for={@contract_form} phx-submit="create_contract"> - <.card> - <.card_header> - <.card_title>Contract Details - - <.card_content class="pt-0 flex flex-col"> -
- <.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""" - <.form for={@bounty_form} phx-submit="create_bounty"> - <.card> - <.card_header> - <.card_title>Bounty Details - - <.card_content class="pt-0 flex flex-col"> -
- <.input type="hidden" name="bounty_form[visibility]" value="exclusive" /> - <.input - type="hidden" - name="bounty_form[shared_with][]" - value={ - case @selected_developer do - %{handle: nil, provider_id: provider_id} -> [to_string(provider_id)] - %{id: id} -> [id] - end - } - /> - <.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""" - <.form for={@tip_form} phx-submit="create_tip"> - <.card> - <.card_header> - <.card_title>Tip Details - - <.card_content class="pt-0 flex flex-col"> -
- - <.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 - - defp share_drawer_developer_info(assigns) do - ~H""" - <.card> - <.card_header> - <.card_title>Developer - - <.card_content class="pt-0"> -
- <.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} - - {Algora.Misc.CountryEmojis.get(@selected_developer.country)} - -
- -
- <.link - :if={@selected_developer.provider_login} - href={"https://github.com/#{@selected_developer.provider_login}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - <.icon name="github" class="h-4 w-4" /> - {@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 %> -
-
-
- - - """ - end - - defp share_drawer(assigns) do - ~H""" - <.drawer show={@show_share_drawer} direction="bottom" 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"> -
- <.share_drawer_developer_info selected_developer={@selected_developer} /> - <.share_drawer_content - :if={@selected_developer} - selected_developer={@selected_developer} - share_drawer_type={@share_drawer_type} - bounty_form={@bounty_form} - tip_form={@tip_form} - contract_form={@contract_form} - /> -
- - - """ - end - - defp social_icons do - %{ - website: "tabler-world", - github: "github", - twitter: "tabler-brand-x", - youtube: "tabler-brand-youtube", - twitch: "tabler-brand-twitch", - discord: "tabler-brand-discord", - slack: "tabler-brand-slack", - linkedin: "tabler-brand-linkedin" - } - end - - defp social_link(user, :github), do: if(login = user.provider_login, do: "https://github.com/#{login}") - defp social_link(user, platform), do: Map.get(user, :"#{platform}_url") -end diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index d7d72c74c..80d2e30a7 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -125,8 +125,6 @@ defmodule AlgoraWeb.Router do live "/community", CommunityLive, :index live "/community/:tech", CommunityLive, :index live "/crowdfund", CrowdfundLive, :index - live "/hn", HNJobsLive, :index - live "/hn/:tab", HNJobsLive, :index live "/pricing", PricingLive live "/challenges", ChallengesLive live "/challenges/prettier", Challenges.PrettierLive diff --git a/mix.exs b/mix.exs index 4491ebebe..bb799aefe 100644 --- a/mix.exs +++ b/mix.exs @@ -99,7 +99,6 @@ defmodule Algora.MixProject do {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}, {:cmark, "~> 0.10"}, {:csv, "~> 3.2"}, - {:instructor, "~> 0.1.0"}, # ex_aws {:ex_aws, "~> 2.1"}, {:ex_aws_s3, "~> 2.0"}, diff --git a/mix.lock b/mix.lock index ebe6ed13c..bef3db3a6 100644 --- a/mix.lock +++ b/mix.lock @@ -46,9 +46,7 @@ "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "instructor": {:hex, :instructor, "0.1.0", "ca587fa11b9de7dff68b6f0a28ee17682d35f67efa20f71aef61bbb528444562", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jaxon, "~> 2.0", [hex: :jaxon, repo: "hexpm", optional: false]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "05a7020a460ca43dc1123c7903e928b678360cd37eac761f472bd8e0787fefcb"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "jaxon": {:hex, :jaxon, "2.0.8", "00951a79d354260e28d7e36f956c3de94818124768a4b22e0fc55559d1b3bfe7", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "74532853b1126609615ea98f0ceb5009e70465ca98027afbbd8ed314d887e82d"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "live_svelte": {:hex, :live_svelte, "0.14.1", "78fcd3bb7eb1c349138ebcaef5b61653bf1a818e42129c847482717895af8f70", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:nodejs, "~> 3.1", [hex: :nodejs, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 3.3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "f96b06456957fbc6d25f71fc984f8f949664d5b61fbfcb3db43d5b82699c39a8"}, @@ -84,7 +82,6 @@ "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "redirect": {:hex, :redirect, "0.4.0", "98b46053504ee517bc3ad2fd04c064b64b48d339e1e18266355b30c4f8bb52b0", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.3 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dfa29a8ecbad066ed0b73b34611cf24c78101719737f37bdf750f39197d67b97"}, - "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, "reverse_proxy_plug": {:hex, :reverse_proxy_plug, "3.0.2", "38fde2f59bca8b219ef4f1ec0c0849a67c6d9705160e426a2354f35399db5c7b", [:mix], [{:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.2 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.3.0 or ~> 0.4.0 or ~> 0.5.0", [hex: :req, repo: "hexpm", optional: true]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: true]}], "hexpm", "31ae5e068f7f504fba1b5c17c31c87966c720809ac15140c6c181440fbd24eda"}, "rustler": {:hex, :rustler, "0.36.1", "2d4b1ff57ea2789a44756a40dbb5fbb73c6ee0a13d031dcba96d0a5542598a6a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "f3fba4ad272970e0d1bc62972fc4a99809651e54a125c5242de9bad4574b2d02"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.2", "5f25cbe220a8fac3e7ad62e6f950fcdca5a5a5f8501835d2823e8c74bf4268d5", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "63d1bd5f8e23096d1ff851839923162096364bac8656a4a3c00d1fff8e83ee0a"}, From 1e69ec2030fcd48b6fb21a954f4be63fba597e6e Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 6 May 2025 15:18:41 +0300 Subject: [PATCH 11/13] remove dbg --- lib/algora/organizations/organizations.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/algora/organizations/organizations.ex b/lib/algora/organizations/organizations.ex index b75ed9e85..953318a17 100644 --- a/lib/algora/organizations/organizations.ex +++ b/lib/algora/organizations/organizations.ex @@ -163,8 +163,6 @@ defmodule Algora.Organizations do org -> org.handle end - dbg(params) - case org do nil -> %User{type: :organization} From 44667172bda6f2a253099d24725f25d53fdec5fd Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 6 May 2025 15:21:18 +0300 Subject: [PATCH 12/13] fix failing test --- lib/algora/psp/connect_countries.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora/psp/connect_countries.ex b/lib/algora/psp/connect_countries.ex index 39b60bf9f..9ec40f82b 100644 --- a/lib/algora/psp/connect_countries.ex +++ b/lib/algora/psp/connect_countries.ex @@ -140,7 +140,7 @@ defmodule Algora.PSP.ConnectCountries do def account_type("BR"), do: :standard def account_type(_), do: :express - @spec regions() :: %{atom() => [String.t()]} + @spec regions() :: %{String.t() => [String.t()]} def regions do %{ "LATAM" => [ From 2ad560db32c3ece33fff33d39e40c44b91eef4e9 Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 6 May 2025 15:31:40 +0300 Subject: [PATCH 13/13] autoupdate domain if nil --- lib/algora_web/live/admin/seed_live.ex | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/algora_web/live/admin/seed_live.ex b/lib/algora_web/live/admin/seed_live.ex index 8813ef77b..75980b490 100644 --- a/lib/algora_web/live/admin/seed_live.ex +++ b/lib/algora_web/live/admin/seed_live.ex @@ -294,6 +294,12 @@ defmodule AlgoraWeb.Admin.SeedLive do defp get_user(%{"org_handle" => handle} = _row) when is_binary(handle) and handle != "" do run_cached(handle, fn -> with {:ok, user} <- Workspace.ensure_user(Algora.Admin.token(), handle) do + if is_nil(user.domain) or String.trim(user.domain) == "" do + user + |> change(%{domain: Algora.Util.to_domain(user.website_url)}) + |> Repo.update() + end + Repo.fetch(User, user.id) end end) @@ -304,6 +310,12 @@ defmodule AlgoraWeb.Admin.SeedLive do run_cached(domain, fn -> with {:ok, user} <- fetch_or_create_user(domain, %{hiring: true, tech_stack: row["tech_stack"]}) do + if is_nil(user.domain) or String.trim(user.domain) == "" do + user + |> change(%{domain: domain}) + |> Repo.update() + end + Repo.fetch(User, user.id) end end)