diff --git a/assets/js/app.ts b/assets/js/app.ts index c25b14833..47de2a739 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -766,4 +766,22 @@ window.addEventListener("phx:open_popup", (e: CustomEvent) => { } }); +// Add event listener for storing session values +window.addEventListener("phx:store-session", (event) => { + const token = document + .querySelector('meta[name="csrf-token"]') + .getAttribute("content"); + + console.log(event.detail); + + fetch("/store-session", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": token, + }, + body: JSON.stringify(event.detail), + }); +}); + export default Hooks; diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index 67b938605..eb9f714cf 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -410,7 +410,7 @@ defmodule Algora.Accounts do end def register_org(params) do - params |> User.org_registration_changeset() |> Repo.insert() + params |> User.org_registration_changeset() |> Repo.insert(returning: true) end def auto_join_orgs(user) do @@ -441,10 +441,10 @@ defmodule Algora.Accounts do orgs end - def get_or_register_user(email) do + def get_or_register_user(email, attr \\ %{}) do res = case get_user_by_email(email) do - nil -> register_org(%{email: email}) + nil -> attr |> Map.put(:email, email) |> register_org() user -> {:ok, user} end diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index f8262991d..31710ebf0 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -114,10 +114,10 @@ defmodule Algora.Accounts.User do def org_registration_changeset(params) do %User{} - |> cast(params, [:email]) + |> cast(params, [:email, :display_name, :type]) |> generate_id() |> validate_required([:email]) - |> validate_email() + |> validate_unique_email() end @doc """ @@ -161,7 +161,7 @@ defmodule Algora.Accounts.User do |> generate_id() |> validate_required([:email, :handle]) |> validate_handle() - |> validate_email() + |> validate_unique_email() |> unique_constraint(:email) |> unique_constraint(:handle) |> put_assoc(:identities, [identity_changeset]) @@ -219,7 +219,7 @@ defmodule Algora.Accounts.User do |> generate_id() |> validate_required([:email, :handle]) |> validate_handle() - |> validate_email() + |> validate_unique_email() |> unique_constraint(:email) |> unique_constraint(:handle) else @@ -249,7 +249,7 @@ defmodule Algora.Accounts.User do ]) |> generate_id() |> validate_required([:type, :handle, :email]) - |> validate_email() + |> validate_unique_email() |> unique_constraint(:handle) |> unique_constraint(:email) end @@ -286,11 +286,16 @@ defmodule Algora.Accounts.User do cast(user, params, [:email, :signup_token]) end - defp validate_email(changeset) do + def validate_email(changeset) do changeset |> validate_required([:email]) |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") |> validate_length(:email, max: 160) + end + + def validate_unique_email(changeset) do + changeset + |> validate_email() |> unsafe_validate_unique(:email, Algora.Repo) |> unique_constraint(:email) end diff --git a/lib/algora/jobs/jobs.ex b/lib/algora/jobs/jobs.ex new file mode 100644 index 000000000..ac4c92aaf --- /dev/null +++ b/lib/algora/jobs/jobs.ex @@ -0,0 +1,110 @@ +defmodule Algora.Jobs do + @moduledoc false + + import Ecto.Changeset + import Ecto.Query + + alias Algora.Accounts + alias Algora.Bounties.LineItem + alias Algora.Jobs.JobApplication + alias Algora.Jobs.JobPosting + alias Algora.Payments + alias Algora.Payments.Transaction + alias Algora.Repo + alias Algora.Util + + require Logger + + def price, do: Money.new(:USD, 499, no_fraction_if_integer: true) + + def list_jobs(opts \\ []) do + JobPosting + |> where([j], j.status == :active) + |> order_by([j], desc: j.inserted_at) + |> maybe_filter_by_tech_stack(opts[:tech_stack]) + |> maybe_limit(opts[:limit]) + |> Repo.all() + |> Repo.preload(:user) + end + + def create_job_posting(attrs) do + %JobPosting{} + |> JobPosting.changeset(attrs) + |> Repo.insert() + end + + defp maybe_filter_by_tech_stack(query, nil), do: query + defp maybe_filter_by_tech_stack(query, []), do: query + + defp maybe_filter_by_tech_stack(query, tech_stack) do + where(query, [j], fragment("? && ?", j.tech_stack, ^tech_stack)) + end + + defp maybe_limit(query, nil), do: query + defp maybe_limit(query, limit), do: limit(query, ^limit) + + @spec create_payment_session(job_posting: JobPosting.t()) :: + {:ok, String.t()} | {:error, atom()} + def create_payment_session(job_posting) do + line_items = [%LineItem{amount: price(), title: "Job posting - #{job_posting.company_name}"}] + + gross_amount = LineItem.gross_amount(line_items) + group_id = Nanoid.generate() + + Repo.transact(fn -> + with {:ok, user} <- + Accounts.get_or_register_user(job_posting.email, %{ + type: :organization, + display_name: job_posting.company_name + }), + {:ok, _charge} <- + %Transaction{} + |> change(%{ + id: Nanoid.generate(), + provider: "stripe", + type: :charge, + status: :initialized, + user_id: user.id, + job_id: job_posting.id, + gross_amount: gross_amount, + net_amount: gross_amount, + total_fee: Money.zero(:USD), + line_items: Util.normalize_struct(line_items), + group_id: group_id, + idempotency_key: "session-#{Nanoid.generate()}" + }) + |> Algora.Validations.validate_positive(:gross_amount) + |> Algora.Validations.validate_positive(:net_amount) + |> foreign_key_constraint(:user_id) + |> unique_constraint([:idempotency_key]) + |> Repo.insert(), + {:ok, session} <- + Payments.create_stripe_session( + user, + Enum.map(line_items, &LineItem.to_stripe/1), + %{ + description: "Job posting - #{job_posting.company_name}", + metadata: %{"version" => Payments.metadata_version(), "group_id" => group_id} + }, + success_url: "#{AlgoraWeb.Endpoint.url()}/jobs?status=paid", + cancel_url: "#{AlgoraWeb.Endpoint.url()}/jobs?status=canceled" + ) do + {:ok, session.url} + end + end) + end + + def create_application(job_id, user) do + %JobApplication{} + |> JobApplication.changeset(%{job_id: job_id, user_id: user.id}) + |> Repo.insert() + end + + def list_user_applications(user) do + JobApplication + |> where([a], a.user_id == ^user.id) + |> select([a], a.job_id) + |> Repo.all() + |> MapSet.new() + end +end diff --git a/lib/algora/jobs/schemas/job_application.ex b/lib/algora/jobs/schemas/job_application.ex new file mode 100644 index 000000000..c5d8984d5 --- /dev/null +++ b/lib/algora/jobs/schemas/job_application.ex @@ -0,0 +1,26 @@ +defmodule Algora.Jobs.JobApplication do + @moduledoc false + use Algora.Schema + + alias Algora.Accounts.User + alias Algora.Jobs.JobPosting + + typed_schema "job_applications" do + field :status, Ecto.Enum, values: [:pending], null: false, default: :pending + + belongs_to :job, JobPosting, null: false + belongs_to :user, User, null: false + + timestamps() + end + + def changeset(job_application, attrs) do + job_application + |> cast(attrs, [:status, :job_id, :user_id]) + |> generate_id() + |> validate_required([:status, :job_id, :user_id]) + |> unique_constraint([:job_id, :user_id]) + |> foreign_key_constraint(:job_id) + |> foreign_key_constraint(:user_id) + end +end diff --git a/lib/algora/jobs/schemas/job_posting.ex b/lib/algora/jobs/schemas/job_posting.ex new file mode 100644 index 000000000..00442e9c0 --- /dev/null +++ b/lib/algora/jobs/schemas/job_posting.ex @@ -0,0 +1,42 @@ +defmodule Algora.Jobs.JobPosting do + @moduledoc false + use Algora.Schema + + alias Algora.Accounts.User + + typed_schema "job_postings" do + field :title, :string + field :description, :string + field :tech_stack, {:array, :string}, default: [] + field :url, :string + field :company_name, :string + field :company_url, :string + field :email, :string + field :status, Ecto.Enum, values: [:initialized, :processing, :active, :expired], null: false, default: :initialized + field :expires_at, :utc_datetime_usec + + belongs_to :user, User, null: false + + timestamps() + end + + def changeset(job_posting, attrs) do + job_posting + |> cast(attrs, [ + :title, + :description, + :tech_stack, + :url, + :company_name, + :company_url, + :email, + :status, + :expires_at, + :user_id + ]) + |> generate_id() + |> validate_required([:url, :company_name, :company_url, :email]) + |> User.validate_email() + |> foreign_key_constraint(:user) + end +end diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index 5294ef1fe..fca0fcd58 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -31,10 +31,14 @@ defmodule Algora.Payments do @spec create_stripe_session( user :: User.t(), line_items :: [PSP.Session.line_item_data()], - payment_intent_data :: PSP.Session.payment_intent_data() + payment_intent_data :: PSP.Session.payment_intent_data(), + opts :: [ + {:success_url, String.t()}, + {:cancel_url, String.t()} + ] ) :: {:ok, PSP.session()} | {:error, PSP.error()} - def create_stripe_session(user, line_items, payment_intent_data) do + def create_stripe_session(user, line_items, payment_intent_data, opts \\ []) do with {:ok, customer} <- fetch_or_create_customer(user) do PSP.Session.create(%{ mode: "payment", @@ -42,8 +46,8 @@ defmodule Algora.Payments do billing_address_collection: "required", line_items: line_items, invoice_creation: %{enabled: true}, - success_url: "#{AlgoraWeb.Endpoint.url()}/payment/success", - cancel_url: "#{AlgoraWeb.Endpoint.url()}/payment/canceled", + success_url: opts[:success_url] || "#{AlgoraWeb.Endpoint.url()}/payment/success", + cancel_url: opts[:cancel_url] || "#{AlgoraWeb.Endpoint.url()}/payment/canceled", payment_intent_data: payment_intent_data }) end diff --git a/lib/algora/payments/schemas/transaction.ex b/lib/algora/payments/schemas/transaction.ex index 450b1f9ec..629c12515 100644 --- a/lib/algora/payments/schemas/transaction.ex +++ b/lib/algora/payments/schemas/transaction.ex @@ -40,6 +40,7 @@ defmodule Algora.Payments.Transaction do belongs_to :claim, Algora.Bounties.Claim belongs_to :bounty, Algora.Bounties.Bounty belongs_to :tip, Algora.Bounties.Tip + belongs_to :job, Algora.Jobs.JobPosting belongs_to :linked_transaction, Algora.Payments.Transaction has_many :activities, {"transaction_activities", Activity}, foreign_key: :assoc_id diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index 522fd7153..3538dd97c 100644 --- a/lib/algora_web/components/core_components.ex +++ b/lib/algora_web/components/core_components.ex @@ -595,7 +595,7 @@ defmodule AlgoraWeb.CoreComponents do <% body -> %> -

+

<.icon :if={@kind == :info} name="tabler-circle-check" class="h-6 w-6 text-success" /> <.icon :if={@kind == :warning} @@ -1236,11 +1236,12 @@ defmodule AlgoraWeb.CoreComponents do attr :title, :string, default: nil attr :subtitle, :string, default: nil attr :link, :string, default: nil + attr :class, :string, default: nil slot :inner_block def section(assigns) do ~H""" -

+

{@title}

diff --git a/lib/algora_web/controllers/store_session_controller.ex b/lib/algora_web/controllers/store_session_controller.ex new file mode 100644 index 000000000..65439c48c --- /dev/null +++ b/lib/algora_web/controllers/store_session_controller.ex @@ -0,0 +1,14 @@ +defmodule AlgoraWeb.StoreSessionController do + use AlgoraWeb, :controller + + def create(conn, params) do + dbg(params) + + updated_conn = + Enum.reduce(params, conn, fn {key, value}, acc_conn -> + put_session(acc_conn, String.to_existing_atom(key), value) + end) + + send_resp(updated_conn, 200, "") + end +end diff --git a/lib/algora_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index 3f2ef66e2..e2b0e0348 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -9,6 +9,7 @@ defmodule AlgoraWeb.Webhooks.StripeController do alias Algora.Bounties.Claim alias Algora.Bounties.Tip alias Algora.Contracts.Contract + alias Algora.Jobs.JobPosting alias Algora.Payments alias Algora.Payments.Customer alias Algora.Payments.Transaction @@ -147,6 +148,7 @@ defmodule AlgoraWeb.Webhooks.StripeController do tip_ids = txs |> Enum.map(& &1.tip_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() contract_ids = txs |> Enum.map(& &1.contract_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() claim_ids = txs |> Enum.map(& &1.claim_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() + job_ids = txs |> Enum.map(& &1.job_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() Repo.update_all(from(b in Bounty, where: b.id in ^bounty_ids), set: [status: :paid]) Repo.update_all(from(t in Tip, where: t.id in ^tip_ids), set: [status: :paid]) @@ -154,6 +156,13 @@ defmodule AlgoraWeb.Webhooks.StripeController do # TODO: add and use a new "paid" status for claims Repo.update_all(from(c in Claim, where: c.id in ^claim_ids), set: [status: :approved]) + {_, job_postings} = + Repo.update_all(from(j in JobPosting, where: j.id in ^job_ids, select: j), set: [status: :processing]) + + for job <- job_postings do + Algora.Admin.alert("Job payment received! #{job.company_name} #{job.email} #{job.url}", :info) + end + activities_result = txs |> Enum.filter(&(&1.type == :credit)) diff --git a/lib/algora_web/live/jobs_live.ex b/lib/algora_web/live/jobs_live.ex new file mode 100644 index 000000000..ae04144af --- /dev/null +++ b/lib/algora_web/live/jobs_live.ex @@ -0,0 +1,342 @@ +defmodule AlgoraWeb.JobsLive do + @moduledoc false + use AlgoraWeb, :live_view + + import Ecto.Changeset + + alias Algora.Accounts + alias Algora.Jobs + alias Algora.Jobs.JobPosting + alias Phoenix.LiveView.AsyncResult + + require Logger + + @impl true + def mount(_params, _session, socket) do + # Group jobs by user + jobs_by_user = Enum.group_by(Jobs.list_jobs(), & &1.user) + changeset = JobPosting.changeset(%JobPosting{}, %{}) + + {:ok, + socket + |> assign(:page_title, "Jobs") + |> assign(:jobs_by_user, jobs_by_user) + |> assign(:form, to_form(changeset)) + |> assign(:user_metadata, AsyncResult.loading()) + |> assign_user_applications()} + end + + @impl true + def render(assigns) do + ~H""" +
+
+
+
+

+ Jobs +

+

+ Open positions at top companies +

+
+ + <.section class="pt-8"> + <%= 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 %> +
+
+
+ <.link + href={job.url} + class="text-lg font-semibold hover:underline" + target="_blank" + > + {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 %> + + + <.section class="pt-12"> +
+
+
+
+ Post your job
+ + in seconds + +
+
+ Reach thousands of developers looking for their next opportunity versed in your tech stack +
+ <.simple_form + for={@form} + phx-change="validate_job" + phx-submit="create_job" + class="mt-4 space-y-6" + > +
+ <.input + field={@form[:email]} + label="Email" + data-domain-target + phx-hook="DeriveDomain" + phx-blur="email_changed" + /> + <.input field={@form[:url]} label="Job Posting URL" /> + <.input field={@form[:company_url]} label="Company URL" data-domain-source /> + <.input field={@form[:company_name]} label="Company Name" /> +
+ +
+
+
+ <%= if logo = get_in(@user_metadata.result, [:org, :favicon_url]) do %> + + <% end %> +
+
+ {get_change(@form.source, :company_name)} +
+ <%= if description = get_in(@user_metadata.result, [:org, :og_description]) do %> +
+ {description} +
+ <% end %> +
+ <%= if url = get_change(@form.source, :company_url) do %> + <.link + href={url} + target="_blank" + class="text-muted-foreground hover:text-foreground" + > + <.icon name="tabler-world" class="size-4" /> + + <% end %> + + <%= for {platform, icon} <- social_icons(), + url = get_in(@user_metadata.result, [:org, :socials, 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 %> +
+
+
+
+ <.button class="flex items-center gap-2" phx-disable-with="Processing..."> + Post Job + +
+ +
+
+
+ +
+
+ """ + end + + @impl true + def handle_params(%{"status" => "paid"}, _uri, socket) do + {:noreply, put_flash(socket, :info, "Payment received, your job will go live shortly!")} + end + + @impl true + def handle_params(_params, _uri, socket) do + {:noreply, socket} + end + + def handle_event("email_changed", %{"value" => email}, socket) do + {:noreply, + socket + |> start_async(:fetch_metadata, fn -> Algora.Crawler.fetch_user_metadata(email) end) + |> assign(:user_metadata, AsyncResult.loading())} + end + + @impl true + def handle_event("validate_job", %{"job_posting" => params}, socket) do + {:noreply, assign(socket, :form, to_form(JobPosting.changeset(socket.assigns.form.source, params)))} + end + + @impl true + def handle_event("create_job", %{"job_posting" => params}, socket) do + case Jobs.create_job_posting(params) do + {:ok, job} -> + case Jobs.create_payment_session(job) do + {:ok, url} -> + {:noreply, redirect(socket, external: url)} + + {:error, reason} -> + Logger.error("Failed to create payment session: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")} + end + + {:error, changeset} -> + Logger.error("Failed to create job posting: #{inspect(changeset)}") + {:noreply, assign(socket, :form, to_form(changeset))} + end + 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_async(:fetch_metadata, {:ok, metadata}, socket) do + {:noreply, + socket + |> assign(:user_metadata, AsyncResult.ok(socket.assigns.user_metadata, metadata)) + |> assign(:form, to_form(change(socket.assigns.form.source, company_name: get_in(metadata, [:org, :og_title]))))} + end + + @impl true + def handle_async(:fetch_metadata, {:exit, reason}, socket) do + {:noreply, assign(socket, :user_metadata, AsyncResult.failed(socket.assigns.user_metadata, reason))} + 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 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 6f124aaaf..dbcd2c412 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -105,6 +105,7 @@ defmodule AlgoraWeb.Router do on_mount: [{AlgoraWeb.UserAuth, :current_user}, AlgoraWeb.User.Nav] do live "/bounties", BountiesLive, :index live "/bounties/:tech", BountiesLive, :index + live "/jobs", JobsLive, :index live "/community", CommunityLive, :index live "/leaderboard", LeaderboardLive, :index live "/projects", OrgsLive, :index @@ -144,6 +145,8 @@ defmodule AlgoraWeb.Router do live "/0/bounties/:id", OG.BountyLive, :show get "/og/*path", OGImageController, :generate + + post "/store-session", StoreSessionController, :create end scope "/api", AlgoraWeb.API do diff --git a/priv/repo/migrations/20250424152036_create_job_postings.exs b/priv/repo/migrations/20250424152036_create_job_postings.exs new file mode 100644 index 000000000..cfba36183 --- /dev/null +++ b/priv/repo/migrations/20250424152036_create_job_postings.exs @@ -0,0 +1,28 @@ +defmodule Algora.Repo.Migrations.CreateJobPostings do + use Ecto.Migration + + def change do + create table(:job_postings, primary_key: false) do + add :id, :string, primary_key: true + add :title, :string + add :description, :text + add :tech_stack, {:array, :string}, default: [] + add :url, :string + add :company_name, :string + add :company_url, :string + add :email, :string + add :status, :string, null: false, default: "initialized" + add :expires_at, :utc_datetime_usec + add :user_id, references(:users, type: :string, on_delete: :restrict), null: false + + timestamps() + end + + create index(:job_postings, [:user_id]) + create index(:job_postings, [:status]) + + alter table(:transactions) do + add :job_id, references(:job_postings, type: :string, on_delete: :restrict) + end + end +end diff --git a/priv/repo/migrations/20250424152037_create_job_applications.exs b/priv/repo/migrations/20250424152037_create_job_applications.exs new file mode 100644 index 000000000..01317d1ac --- /dev/null +++ b/priv/repo/migrations/20250424152037_create_job_applications.exs @@ -0,0 +1,18 @@ +defmodule Algora.Repo.Migrations.CreateJobApplications do + use Ecto.Migration + + def change do + create table(:job_applications, primary_key: false) do + add :id, :string, primary_key: true + add :status, :string, null: false, default: "pending" + add :job_id, references(:job_postings, type: :string, on_delete: :restrict), null: false + add :user_id, references(:users, type: :string, on_delete: :restrict), null: false + + timestamps() + end + + create index(:job_applications, [:job_id]) + create index(:job_applications, [:user_id]) + create unique_index(:job_applications, [:job_id, :user_id]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index c74967a72..bf493241e 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -495,3 +495,30 @@ for {repo_name, issues} <- repos do end end end + +job_postings = [ + %{ + title: "Senior Backend Engineer", + description: "Help us scale our middle-out compression platform", + tech_stack: ["Python", "C++", "Rust"], + url: "https://piedpiper.com/careers/backend-engineer", + company_name: "Pied Piper", + company_url: "https://piedpiper.com", + email: "jobs@piedpiper.com", + user_id: pied_piper.id + }, + %{ + title: "Frontend Developer", + description: "Build beautiful interfaces for our compression platform", + tech_stack: ["JavaScript", "React", "TypeScript"], + url: "https://piedpiper.com/careers/frontend-developer", + company_name: "Pied Piper", + company_url: "https://piedpiper.com", + email: "jobs@piedpiper.com", + user_id: pied_piper.id + } +] + +for job_attrs <- job_postings do + insert!(:job_posting, Map.put(job_attrs, :status, :active)) +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 38f016f89..6ecf0080c 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -299,6 +299,21 @@ defmodule Algora.Factory do } end + def job_posting_factory do + %Algora.Jobs.JobPosting{ + id: Nanoid.generate(), + title: "Senior Software Engineer", + description: "Join our team to build the next generation of compression technology", + tech_stack: ["Python", "C++", "Rust"], + url: "https://piedpiper.com/careers/senior-engineer", + company_name: "Pied Piper", + company_url: "https://piedpiper.com", + email: "jobs@piedpiper.com", + status: :active, + expires_at: days_from_now(30) + } + end + # Convenience API def insert!(factory_name, attributes \\ [])