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/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/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" => [ 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..75980b490 --- /dev/null +++ b/lib/algora_web/live/admin/seed_live.ex @@ -0,0 +1,442 @@ +defmodule AlgoraWeb.Admin.SeedLive do + @moduledoc false + + use AlgoraWeb, :live_view + + import Ecto.Changeset + import Ecto.Query + + 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 %> +
+ <%= if @value.domain do %> +

+ {@value.domain} +

+ <% else %> +

+ Domain not found +

+ <% end %> +
+
+ """ + 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 + |> 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) + |> 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 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 + Algora.Util.to_domain(url) + end + + defp run_cached(key, fun) do + case :ets.lookup(@user_cache_table, key) do + [{_, user}] -> + user + + _ -> + 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 + 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) + end + + defp get_user(%{"company_url" => url} = row) when is_binary(url) and url != "" do + 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 + 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) + end + + 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 + %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(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + 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" => + 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" => money_from_string(row["price"]) + }) + end + + defp seed_rows(rows) do + Repo.transact( + fn -> + rows + |> Enum.filter(& &1["org"]) + |> 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 seed_row(row) do + with {:ok, org} <- Repo.fetch(User, row["org"].id), + {:ok, org} <- + org + |> change( + Map.merge( + %{ + 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"], + 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() 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 + + 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",