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""" -
+ Open positions at top companies +
+