Skip to content

Commit 22f6771

Browse files
authored
feat: jobs (#123)
1 parent cb04889 commit 22f6771

File tree

17 files changed

+678
-15
lines changed

17 files changed

+678
-15
lines changed

assets/js/app.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,4 +766,22 @@ window.addEventListener("phx:open_popup", (e: CustomEvent) => {
766766
}
767767
});
768768

769+
// Add event listener for storing session values
770+
window.addEventListener("phx:store-session", (event) => {
771+
const token = document
772+
.querySelector('meta[name="csrf-token"]')
773+
.getAttribute("content");
774+
775+
console.log(event.detail);
776+
777+
fetch("/store-session", {
778+
method: "POST",
779+
headers: {
780+
"Content-Type": "application/json",
781+
"X-CSRF-Token": token,
782+
},
783+
body: JSON.stringify(event.detail),
784+
});
785+
});
786+
769787
export default Hooks;

lib/algora/accounts/accounts.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ defmodule Algora.Accounts do
410410
end
411411

412412
def register_org(params) do
413-
params |> User.org_registration_changeset() |> Repo.insert()
413+
params |> User.org_registration_changeset() |> Repo.insert(returning: true)
414414
end
415415

416416
def auto_join_orgs(user) do
@@ -441,10 +441,10 @@ defmodule Algora.Accounts do
441441
orgs
442442
end
443443

444-
def get_or_register_user(email) do
444+
def get_or_register_user(email, attr \\ %{}) do
445445
res =
446446
case get_user_by_email(email) do
447-
nil -> register_org(%{email: email})
447+
nil -> attr |> Map.put(:email, email) |> register_org()
448448
user -> {:ok, user}
449449
end
450450

lib/algora/accounts/schemas/user.ex

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,10 @@ defmodule Algora.Accounts.User do
114114

115115
def org_registration_changeset(params) do
116116
%User{}
117-
|> cast(params, [:email])
117+
|> cast(params, [:email, :display_name, :type])
118118
|> generate_id()
119119
|> validate_required([:email])
120-
|> validate_email()
120+
|> validate_unique_email()
121121
end
122122

123123
@doc """
@@ -161,7 +161,7 @@ defmodule Algora.Accounts.User do
161161
|> generate_id()
162162
|> validate_required([:email, :handle])
163163
|> validate_handle()
164-
|> validate_email()
164+
|> validate_unique_email()
165165
|> unique_constraint(:email)
166166
|> unique_constraint(:handle)
167167
|> put_assoc(:identities, [identity_changeset])
@@ -219,7 +219,7 @@ defmodule Algora.Accounts.User do
219219
|> generate_id()
220220
|> validate_required([:email, :handle])
221221
|> validate_handle()
222-
|> validate_email()
222+
|> validate_unique_email()
223223
|> unique_constraint(:email)
224224
|> unique_constraint(:handle)
225225
else
@@ -249,7 +249,7 @@ defmodule Algora.Accounts.User do
249249
])
250250
|> generate_id()
251251
|> validate_required([:type, :handle, :email])
252-
|> validate_email()
252+
|> validate_unique_email()
253253
|> unique_constraint(:handle)
254254
|> unique_constraint(:email)
255255
end
@@ -286,11 +286,16 @@ defmodule Algora.Accounts.User do
286286
cast(user, params, [:email, :signup_token])
287287
end
288288

289-
defp validate_email(changeset) do
289+
def validate_email(changeset) do
290290
changeset
291291
|> validate_required([:email])
292292
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
293293
|> validate_length(:email, max: 160)
294+
end
295+
296+
def validate_unique_email(changeset) do
297+
changeset
298+
|> validate_email()
294299
|> unsafe_validate_unique(:email, Algora.Repo)
295300
|> unique_constraint(:email)
296301
end

lib/algora/jobs/jobs.ex

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
defmodule Algora.Jobs do
2+
@moduledoc false
3+
4+
import Ecto.Changeset
5+
import Ecto.Query
6+
7+
alias Algora.Accounts
8+
alias Algora.Bounties.LineItem
9+
alias Algora.Jobs.JobApplication
10+
alias Algora.Jobs.JobPosting
11+
alias Algora.Payments
12+
alias Algora.Payments.Transaction
13+
alias Algora.Repo
14+
alias Algora.Util
15+
16+
require Logger
17+
18+
def price, do: Money.new(:USD, 499, no_fraction_if_integer: true)
19+
20+
def list_jobs(opts \\ []) do
21+
JobPosting
22+
|> where([j], j.status == :active)
23+
|> order_by([j], desc: j.inserted_at)
24+
|> maybe_filter_by_tech_stack(opts[:tech_stack])
25+
|> maybe_limit(opts[:limit])
26+
|> Repo.all()
27+
|> Repo.preload(:user)
28+
end
29+
30+
def create_job_posting(attrs) do
31+
%JobPosting{}
32+
|> JobPosting.changeset(attrs)
33+
|> Repo.insert()
34+
end
35+
36+
defp maybe_filter_by_tech_stack(query, nil), do: query
37+
defp maybe_filter_by_tech_stack(query, []), do: query
38+
39+
defp maybe_filter_by_tech_stack(query, tech_stack) do
40+
where(query, [j], fragment("? && ?", j.tech_stack, ^tech_stack))
41+
end
42+
43+
defp maybe_limit(query, nil), do: query
44+
defp maybe_limit(query, limit), do: limit(query, ^limit)
45+
46+
@spec create_payment_session(job_posting: JobPosting.t()) ::
47+
{:ok, String.t()} | {:error, atom()}
48+
def create_payment_session(job_posting) do
49+
line_items = [%LineItem{amount: price(), title: "Job posting - #{job_posting.company_name}"}]
50+
51+
gross_amount = LineItem.gross_amount(line_items)
52+
group_id = Nanoid.generate()
53+
54+
Repo.transact(fn ->
55+
with {:ok, user} <-
56+
Accounts.get_or_register_user(job_posting.email, %{
57+
type: :organization,
58+
display_name: job_posting.company_name
59+
}),
60+
{:ok, _charge} <-
61+
%Transaction{}
62+
|> change(%{
63+
id: Nanoid.generate(),
64+
provider: "stripe",
65+
type: :charge,
66+
status: :initialized,
67+
user_id: user.id,
68+
job_id: job_posting.id,
69+
gross_amount: gross_amount,
70+
net_amount: gross_amount,
71+
total_fee: Money.zero(:USD),
72+
line_items: Util.normalize_struct(line_items),
73+
group_id: group_id,
74+
idempotency_key: "session-#{Nanoid.generate()}"
75+
})
76+
|> Algora.Validations.validate_positive(:gross_amount)
77+
|> Algora.Validations.validate_positive(:net_amount)
78+
|> foreign_key_constraint(:user_id)
79+
|> unique_constraint([:idempotency_key])
80+
|> Repo.insert(),
81+
{:ok, session} <-
82+
Payments.create_stripe_session(
83+
user,
84+
Enum.map(line_items, &LineItem.to_stripe/1),
85+
%{
86+
description: "Job posting - #{job_posting.company_name}",
87+
metadata: %{"version" => Payments.metadata_version(), "group_id" => group_id}
88+
},
89+
success_url: "#{AlgoraWeb.Endpoint.url()}/jobs?status=paid",
90+
cancel_url: "#{AlgoraWeb.Endpoint.url()}/jobs?status=canceled"
91+
) do
92+
{:ok, session.url}
93+
end
94+
end)
95+
end
96+
97+
def create_application(job_id, user) do
98+
%JobApplication{}
99+
|> JobApplication.changeset(%{job_id: job_id, user_id: user.id})
100+
|> Repo.insert()
101+
end
102+
103+
def list_user_applications(user) do
104+
JobApplication
105+
|> where([a], a.user_id == ^user.id)
106+
|> select([a], a.job_id)
107+
|> Repo.all()
108+
|> MapSet.new()
109+
end
110+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
defmodule Algora.Jobs.JobApplication do
2+
@moduledoc false
3+
use Algora.Schema
4+
5+
alias Algora.Accounts.User
6+
alias Algora.Jobs.JobPosting
7+
8+
typed_schema "job_applications" do
9+
field :status, Ecto.Enum, values: [:pending], null: false, default: :pending
10+
11+
belongs_to :job, JobPosting, null: false
12+
belongs_to :user, User, null: false
13+
14+
timestamps()
15+
end
16+
17+
def changeset(job_application, attrs) do
18+
job_application
19+
|> cast(attrs, [:status, :job_id, :user_id])
20+
|> generate_id()
21+
|> validate_required([:status, :job_id, :user_id])
22+
|> unique_constraint([:job_id, :user_id])
23+
|> foreign_key_constraint(:job_id)
24+
|> foreign_key_constraint(:user_id)
25+
end
26+
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
defmodule Algora.Jobs.JobPosting do
2+
@moduledoc false
3+
use Algora.Schema
4+
5+
alias Algora.Accounts.User
6+
7+
typed_schema "job_postings" do
8+
field :title, :string
9+
field :description, :string
10+
field :tech_stack, {:array, :string}, default: []
11+
field :url, :string
12+
field :company_name, :string
13+
field :company_url, :string
14+
field :email, :string
15+
field :status, Ecto.Enum, values: [:initialized, :processing, :active, :expired], null: false, default: :initialized
16+
field :expires_at, :utc_datetime_usec
17+
18+
belongs_to :user, User, null: false
19+
20+
timestamps()
21+
end
22+
23+
def changeset(job_posting, attrs) do
24+
job_posting
25+
|> cast(attrs, [
26+
:title,
27+
:description,
28+
:tech_stack,
29+
:url,
30+
:company_name,
31+
:company_url,
32+
:email,
33+
:status,
34+
:expires_at,
35+
:user_id
36+
])
37+
|> generate_id()
38+
|> validate_required([:url, :company_name, :company_url, :email])
39+
|> User.validate_email()
40+
|> foreign_key_constraint(:user)
41+
end
42+
end

lib/algora/payments/payments.ex

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,23 @@ defmodule Algora.Payments do
3131
@spec create_stripe_session(
3232
user :: User.t(),
3333
line_items :: [PSP.Session.line_item_data()],
34-
payment_intent_data :: PSP.Session.payment_intent_data()
34+
payment_intent_data :: PSP.Session.payment_intent_data(),
35+
opts :: [
36+
{:success_url, String.t()},
37+
{:cancel_url, String.t()}
38+
]
3539
) ::
3640
{:ok, PSP.session()} | {:error, PSP.error()}
37-
def create_stripe_session(user, line_items, payment_intent_data) do
41+
def create_stripe_session(user, line_items, payment_intent_data, opts \\ []) do
3842
with {:ok, customer} <- fetch_or_create_customer(user) do
3943
PSP.Session.create(%{
4044
mode: "payment",
4145
customer: customer.provider_id,
4246
billing_address_collection: "required",
4347
line_items: line_items,
4448
invoice_creation: %{enabled: true},
45-
success_url: "#{AlgoraWeb.Endpoint.url()}/payment/success",
46-
cancel_url: "#{AlgoraWeb.Endpoint.url()}/payment/canceled",
49+
success_url: opts[:success_url] || "#{AlgoraWeb.Endpoint.url()}/payment/success",
50+
cancel_url: opts[:cancel_url] || "#{AlgoraWeb.Endpoint.url()}/payment/canceled",
4751
payment_intent_data: payment_intent_data
4852
})
4953
end

lib/algora/payments/schemas/transaction.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ defmodule Algora.Payments.Transaction do
4040
belongs_to :claim, Algora.Bounties.Claim
4141
belongs_to :bounty, Algora.Bounties.Bounty
4242
belongs_to :tip, Algora.Bounties.Tip
43+
belongs_to :job, Algora.Jobs.JobPosting
4344
belongs_to :linked_transaction, Algora.Payments.Transaction
4445

4546
has_many :activities, {"transaction_activities", Activity}, foreign_key: :assoc_id

lib/algora_web/components/core_components.ex

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ defmodule AlgoraWeb.CoreComponents do
595595
</div>
596596
</div>
597597
<% body -> %>
598-
<p class="text-[0.8125rem] flex items-center gap-3 font-semibold">
598+
<p class="pr-4 text-[0.8125rem] flex items-center gap-3 font-semibold">
599599
<.icon :if={@kind == :info} name="tabler-circle-check" class="h-6 w-6 text-success" />
600600
<.icon
601601
:if={@kind == :warning}
@@ -1236,11 +1236,12 @@ defmodule AlgoraWeb.CoreComponents do
12361236
attr :title, :string, default: nil
12371237
attr :subtitle, :string, default: nil
12381238
attr :link, :string, default: nil
1239+
attr :class, :string, default: nil
12391240
slot :inner_block
12401241

12411242
def section(assigns) do
12421243
~H"""
1243-
<div class="relative h-full">
1244+
<div class={classes(["relative h-full", @class])}>
12441245
<div :if={@title} class="flex items-end justify-between pb-2">
12451246
<div class="flex flex-col space-y-1.5">
12461247
<h2 class="text-2xl font-semibold leading-none tracking-tight">{@title}</h2>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
defmodule AlgoraWeb.StoreSessionController do
2+
use AlgoraWeb, :controller
3+
4+
def create(conn, params) do
5+
dbg(params)
6+
7+
updated_conn =
8+
Enum.reduce(params, conn, fn {key, value}, acc_conn ->
9+
put_session(acc_conn, String.to_existing_atom(key), value)
10+
end)
11+
12+
send_resp(updated_conn, 200, "")
13+
end
14+
end

0 commit comments

Comments
 (0)