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",