diff --git a/config/config.exs b/config/config.exs index b89a152f..61c2704a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -59,6 +59,11 @@ config :ex_aws, host: "ewr1.vultrobjects.com" ] +config :erlef, Oban, + repo: Erlef.Repo, + plugins: [Oban.Plugins.Pruner], + queues: [default: 10] + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/config/test.exs b/config/test.exs index 49600527..81a40c3c 100644 --- a/config/test.exs +++ b/config/test.exs @@ -56,4 +56,6 @@ config :honeybadger, api_key: "test", exclude_envs: [:test] +config :erlef, Oban, testing: :inline + config :erlef, api_key: "key" diff --git a/lib/erlef/accounts/member.ex b/lib/erlef/accounts/member.ex index 5b4d9024..7672c8a2 100644 --- a/lib/erlef/accounts/member.ex +++ b/lib/erlef/accounts/member.ex @@ -1,6 +1,9 @@ defmodule Erlef.Accounts.Member do use Erlef.Schema + alias Erlef.Jobs.Post + alias Erlef.Groups.Sponsor + @moduledoc """ Erlef.Accounts.Member provides a schema and helper functions for working with erlef members. @@ -59,7 +62,12 @@ defmodule Erlef.Accounts.Member do field(:has_requested_slack_invite, :boolean, default: false) field(:requested_slack_invite_at, :utc_datetime) field(:deactivated_at, :utc_datetime) + embeds_one(:external, Erlef.Accounts.External, on_replace: :update) + + belongs_to(:sponsor, Sponsor) + has_many(:posts, Post, foreign_key: :created_by) + timestamps() end @@ -84,7 +92,8 @@ defmodule Erlef.Accounts.Member do :requested_slack_invite_at, :suspended_member, :terms_of_use_accepted, - :deactivated_at + :deactivated_at, + :sponsor_id ] @required_fields [ diff --git a/lib/erlef/admins.ex b/lib/erlef/admins.ex index 3fee2581..0ce5e8c9 100644 --- a/lib/erlef/admins.ex +++ b/lib/erlef/admins.ex @@ -32,7 +32,8 @@ defmodule Erlef.Admins do (select count(id) from volunteers), (select count(id) from working_groups), (select count(id) from sponsors), - (select count(id) from events where events.approved = false), + (select count(id) from events where events.approved = false), + (select count(id) from job_posts where job_posts.approved = false), (select count(id) from member_email_requests where member_email_requests.status != 'complete'), (select count(id) from apps) """ @@ -44,6 +45,7 @@ defmodule Erlef.Admins do working_group_count, sponsors_count, unapproved_events_count, + unapproved_job_posts_count, outstanding_email_requests_count, apps_count ] @@ -55,6 +57,7 @@ defmodule Erlef.Admins do working_groups_count: working_group_count, sponsors_count: sponsors_count, unapproved_events_count: unapproved_events_count, + unapproved_job_posts_count: unapproved_job_posts_count, outstanding_email_requests_count: outstanding_email_requests_count, apps_count: apps_count } diff --git a/lib/erlef/admins/notifications.ex b/lib/erlef/admins/notifications.ex index 11b8cef1..e75e22dd 100644 --- a/lib/erlef/admins/notifications.ex +++ b/lib/erlef/admins/notifications.ex @@ -3,7 +3,8 @@ defmodule Erlef.Admins.Notifications do import Swoosh.Email - @type notification_type() :: :new_email_request | :new_event_submitted | :new_slack_invite + @type notification_type() :: + :new_email_request | :new_event_submitted | :new_slack_invite | :new_job_post_submitted @type params() :: map() @@ -31,4 +32,16 @@ defmodule Erlef.Admins.Notifications do |> subject("A new event was submitted") |> text_body(msg) end + + def new_job_post_submission() do + msg = """ + A new job post was submitted. Visit https://erlef.org/admin/ to view unapproved events. + """ + + new() + |> to({"Website Admins", "infra@erlef.org"}) + |> from({"Erlef Notifications", "notifications@erlef.org"}) + |> subject("A new job poar was submitted") + |> text_body(msg) + end end diff --git a/lib/erlef/application.ex b/lib/erlef/application.ex index 2e62a150..18517a74 100644 --- a/lib/erlef/application.ex +++ b/lib/erlef/application.ex @@ -29,6 +29,7 @@ defmodule Erlef.Application do defp base_children() do [ Erlef.Repo, + {Oban, Application.fetch_env!(:erlef, Oban)}, Erlef.Repo.ETS, Erlef.Repo.ETS.Importer, ErlefWeb.Telemetry, diff --git a/lib/erlef/groups/sponsor.ex b/lib/erlef/groups/sponsor.ex index d8d63523..7addfc21 100644 --- a/lib/erlef/groups/sponsor.ex +++ b/lib/erlef/groups/sponsor.ex @@ -1,7 +1,10 @@ defmodule Erlef.Groups.Sponsor do @moduledoc false + use Erlef.Schema + alias Erlef.Accounts.Member + schema "sponsors" do field(:active, :boolean, default: true) field(:logo, :string, virtual: true) @@ -12,6 +15,9 @@ defmodule Erlef.Groups.Sponsor do field(:created_by, Ecto.UUID) field(:updated_by, Ecto.UUID) + has_many(:members, Member) + has_many(:posts, through: [:members, :posts]) + timestamps() end diff --git a/lib/erlef/jobs.ex b/lib/erlef/jobs.ex new file mode 100644 index 00000000..594ebcc0 --- /dev/null +++ b/lib/erlef/jobs.ex @@ -0,0 +1,354 @@ +defmodule Erlef.Jobs do + @moduledoc """ + The Jobs context. + """ + + alias Ecto.Changeset + alias Erlef.Repo + alias __MODULE__.Error + alias Erlef.Jobs.Post + alias Erlef.Jobs.PostHistoryEntry + alias Erlef.Accounts.Member + + import Ecto.Query + + @max_posts_per_author 4 + + @type create_post_params :: %{ + title: String.t(), + description: String.t(), + position_type: :permanent | :contractor, + city: String.t() | nil, + region: String.t() | nil, + country: String.t() | nil, + remote: boolean(), + website: URI.t(), + days_to_live: pos_integer() | nil + } + + @type update_post_params :: %{ + title: String.t(), + description: String.t(), + position_type: :permanent | :contractor, + city: String.t() | nil, + region: String.t() | nil, + country: String.t() | nil, + remote: boolean(), + website: URI.t() + } + + @doc """ + Retrieves all job posts which are approved and not expired. + + Sposored posts are in front of the list. + """ + @spec list_posts() :: [Post.t()] + def list_posts() do + Post.where_approved() + |> Post.where_fresh() + |> Post.order_by_sponsor_owner_asc() + |> Repo.all() + end + + @doc """ + Retrieves all job posts which are not approved. + """ + @spec list_unapproved_posts() :: [Post.t()] + def list_unapproved_posts() do + Post.from() + |> Post.where_unapproved() + |> Repo.all() + end + + @doc """ + Retrieves all job posts by an author with the provided id. + """ + @spec list_posts_by_author_id(term()) :: [Post.t()] + def list_posts_by_author_id(author_id) do + author_id + |> Post.where_author_id() + |> Repo.all() + end + + @doc """ + Retrieves a job post by its id. + """ + @spec get_post!(term()) :: Post.t() + def get_post!(id) do + id + |> Post.where_id() + |> Repo.one!() + end + + @doc """ + Creates a job post. + + A job post can be created by a sponsor-associated member if the sponsor hasn't + yet reached the posts quota. If the member is not associated to a sponsor, the + rule is applied to the member itself. + """ + @spec create_post(Member.t(), create_post_params()) :: + {:ok, Post.t()} | {:error, __MODULE__.Error.t()} + def create_post(%Member{} = member, attrs \\ %{}) do + Repo.transact(fn -> + with {:authz, true} <- {:authz, can_create_post?(member)}, + {:ok, post} <- do_insert_post(member, attrs), + created_by = post.created_by, + :ok <- update_post_history(:insert, created_by, post), + :ok <- send_post_submission_notifications(created_by) do + {:ok, post} + else + {:authz, false} -> + {:error, Error.exception(:post_quota_reached)} + + {:error, %Changeset{} = cs} -> + {:error, Error.exception({:changeset, cs})} + end + end) + end + + @spec do_insert_post(Member.t(), create_post_params()) :: + {:ok, Post.t()} | {:error, Changeset.t()} + defp do_insert_post(member, attrs) do + result = + member + |> Ecto.build_assoc(:posts) + |> Post.changeset(attrs) + |> Repo.insert() + + case result do + {:ok, post} -> + {:ok, Map.put(post, :updated_by, post.created_by)} + + error -> + error + end + end + + @doc """ + Approves a job post. + + The job post can be approved only by a member who is an admin. + """ + @spec approve_post(Member.t(), Post.t()) :: {:ok, Post.t()} | {:error, :unauthorized} + def approve_post(%Member{is_app_admin: true} = member, %Post{} = post) do + with {:ok, %Post{} = post} <- update_post(member, post, %{approved: true}), + {:ok, _} <- send_post_approval_notification(post.created_by, post.title) do + {:ok, post} + else + error -> + error + end + end + + def approve_post(%Member{}, %Post{}) do + {:error, Error.exception(:unauthorized)} + end + + @doc """ + Updates a job post. + + The job post can be updated by its author, by a member associated to the same + sponsor as the author, or an admin. + """ + @spec update_post(Member.t(), Post.t(), update_post_params()) :: + {:ok, Post.t()} | {:error, Changeset.t() | :unauthorized} + def update_post(%Member{} = member, %Post{} = post, %{} = attrs) do + if owns_post?(member, post) do + Repo.transact(fn -> + with {:ok, post} <- do_update_post(member, post, attrs), + :ok <- update_post_history(:update, post.updated_by, post) do + {:ok, post} + else + {:error, %Changeset{} = cs} -> + {:error, Error.exception({:changeset, cs})} + end + end) + else + {:error, Error.exception(:unauthorized)} + end + end + + @spec do_update_post(Member.t(), Post.t(), update_post_params()) :: + {:ok, Post.t()} | {:error, Changeset.t()} + defp do_update_post(%Member{id: updated_by}, %Post{} = post, %{} = attrs) do + updated = + post + |> Post.changeset(attrs) + |> Repo.update() + + case updated do + {:ok, post} -> + {:ok, Map.put(post, :updated_by, updated_by)} + + error -> + error + end + end + + @doc """ + Deletes a job post. + + The job post can be deleted by its author, by a member associated to the same + sponsor as the author, or an admin. + """ + @spec delete_post(Member.t(), Post.t()) :: + {:ok, Post.t()} | {:error, Changeset.t() | :unauthorized} + def delete_post(%Member{} = member, %Post{} = post) do + if owns_post?(member, post) do + Repo.transaction(fn -> + with {:ok, deleted_post} <- Repo.delete(post), + deleted_by = member.id, + :ok <- update_post_history(:delete, deleted_by, deleted_post) do + deleted_post + else + {:error, %Changeset{} = cs} -> + Repo.rollback(Error.exception({:changeset, cs})) + end + end) + else + {:error, Error.exception(:unauthorized)} + end + end + + @spec change_post(Post.t(), map()) :: Changeset.t() + def change_post(%Post{} = post, attrs \\ %{}) do + Post.changeset(post, attrs) + end + + @spec sponsored_post?(Post.t()) :: boolean() + def sponsored_post?(%Post{sponsor_id: nil}), do: false + def sponsored_post?(%Post{}), do: true + + @doc """ + Returns whether a member owns a job post. + + A member owns a job post if the member is an admin, the creator of the post, + or another member associated to the same sponsor is the author. + """ + @spec owns_post?(Member.t(), Post.t()) :: boolean() + def owns_post?(%Member{is_app_admin: true}, %Post{}), do: true + + # Both `created_by` and `id` are required so there's no need to check for them + # being non-nil. + def owns_post?(%Member{id: id}, %Post{created_by: id}), do: true + + def owns_post?(%Member{sponsor_id: id}, %Post{sponsor_id: id}) when not is_nil(id), + do: true + + def owns_post?(%Member{}, %Post{}), do: false + + @doc """ + Returns wether a member or the sponsor it's related, if it's related to a + sponsor at all, to has reached the posts quota. + """ + @spec can_create_post?(Member.t()) :: boolean() + def can_create_post?(%Member{id: member_id}) do + member_id + |> Post.where_owner_id() + |> Post.where_inserted_in_current_year() + |> Repo.count() + |> then(negate(&reached_posts_quota?/1)) + end + + defp reached_posts_quota?(count) when count >= @max_posts_per_author, do: true + + defp reached_posts_quota?(_), do: false + + defp negate(f) when is_function(f, 1) do + &(!f.(&1)) + end + + # This shouldn't be an application-level concern. History tracking is best + # performed by the system that stores the data. Proper implementation involves + # writing SQL triggers which can be hairy. + @spec update_post_history(:insert | :update | :delete, term(), Post.t()) :: :ok + defp update_post_history(action, member_id, post) do + {:ok, _} = + Repo.transaction(fn -> + case action do + :insert -> + :ok = create_post_history_entry(member_id, post) + + :delete -> + :ok = delete_post_history_entry(member_id, post.id) + + :update -> + :ok = delete_post_history_entry(member_id, post.id) + :ok = create_post_history_entry(member_id, post) + end + end) + + :ok + end + + defp delete_post_history_entry(member_id, post_id) do + history_entry_query = + from(ph in PostHistoryEntry, + where: fragment("?::tstzrange @> current_timestamp", ph.valid_range), + where: [id: ^post_id], + update: [ + set: [ + deleted_by: ^member_id, + deleted_at: fragment("current_timestamp") + ] + ] + ) + + {1, _} = Repo.update_all(history_entry_query, []) + + :ok + end + + defp create_post_history_entry(member_id, %Post{} = post) do + attrs = + post + |> Map.from_struct() + |> Map.put(:created_by, member_id) + + {:ok, _} = + %PostHistoryEntry{} + |> PostHistoryEntry.changeset(attrs) + |> Repo.insert() + + :ok + end + + defp send_post_submission_notifications(member_id) do + params = [ + %{ + module: Erlef.Admins.Notifications, + fun: :new_job_post_submission, + args: [] + }, + %{ + module: Erlef.Members.Notifications, + fun: :new_job_post_submission, + args: [member_id] + } + ] + + [_, _] = + params + |> Enum.map(&Erlef.Outbox.Email.new/1) + |> Oban.insert_all() + + :ok + end + + defp send_post_approval_notification(member_id, post_title) do + %{ + module: Erlef.Members.Notifications, + fun: :job_post_approval, + args: [member_id, post_title] + } + |> Erlef.Outbox.Email.new() + |> Oban.insert() + end + + def format_error({:changeset, cs}), do: inspect(cs) + + def format_error(:post_quota_reached), do: "The member has reached the post quota." + + def format_error(:unauthorized), do: "The member is unauthorized to perform this operation." +end diff --git a/lib/erlef/jobs/error.ex b/lib/erlef/jobs/error.ex new file mode 100644 index 00000000..fd9eba89 --- /dev/null +++ b/lib/erlef/jobs/error.ex @@ -0,0 +1,28 @@ +defmodule Erlef.Jobs.Error do + @moduledoc """ + A consolidated structure to represent an error in the Jobs context. + """ + + @type reason :: + {:changeset, Ecto.Changeset.t()} + | :unathorized + | :post_quota_reached + + @type t :: %__MODULE__{ + reason: term() + } + + defexception [:reason] + + @doc """ + Constructs a new error struct with the provided reason. + """ + @spec exception(term()) :: t() + def exception(reason), do: %__MODULE__{reason: reason} + + @doc """ + Returns the error's message. + """ + @spec message(t()) :: String.t() + def message(%__MODULE__{reason: reason}), do: Erlef.Jobs.format_error(reason) +end diff --git a/lib/erlef/jobs/interval_type.ex b/lib/erlef/jobs/interval_type.ex new file mode 100644 index 00000000..6672f47f --- /dev/null +++ b/lib/erlef/jobs/interval_type.ex @@ -0,0 +1,58 @@ +defmodule Erlef.Jobs.IntervalType do + @moduledoc """ + A custom Ecto type for dealing with Postgres intervals. + """ + + use Ecto.Type + + alias Ecto.Changeset + + @impl true + def type(), do: :interval + + @impl true + def cast(%{} = params) do + data = %{ + months: 0, + days: 0, + secs: 0, + microsecs: 0 + } + + types = %{ + months: :integer, + days: :integer, + secs: :integer, + microsecs: :integer + } + + cast_result = + {data, types} + |> Changeset.cast(params, Map.keys(types)) + |> Changeset.apply_action(:insert) + + case cast_result do + {:error, _} -> + :error + + success -> + success + end + end + + def cast(_), do: :error + + @impl true + def load(%{months: months, days: days, secs: secs}) do + {:ok, %Postgrex.Interval{months: months, days: days, secs: secs}} + end + + @impl true + def dump(%{months: months, days: days, secs: secs}) do + {:ok, %Postgrex.Interval{months: months, days: days, secs: secs}} + end + + def dump(%{"months" => months, "days" => days, "secs" => secs}) do + {:ok, %Postgrex.Interval{months: months, days: days, secs: secs}} + end +end diff --git a/lib/erlef/jobs/post.ex b/lib/erlef/jobs/post.ex new file mode 100644 index 00000000..68d1163c --- /dev/null +++ b/lib/erlef/jobs/post.ex @@ -0,0 +1,159 @@ +defmodule Erlef.Jobs.Post do + @moduledoc false + + use Erlef.Schema + + alias Ecto.Changeset + alias Ecto.Query + alias Erlef.Jobs.IntervalType + alias Erlef.Jobs.PostHistoryEntry + alias Erlef.Jobs.URIType + alias Erlef.Accounts.Member + + import Ecto.Changeset + import Ecto.Query + + @required_fields [:title, :description, :position_type, :website] + @optional_fields [:approved, :city, :region, :country, :remote, :ttl] + @fields @required_fields ++ @optional_fields + + schema "job_posts" do + field(:title, :string) + field(:description, :string) + field(:position_type, Ecto.Enum, values: [:permanent, :contractor]) + field(:approved, :boolean, default: false) + field(:city, :string) + field(:region, :string) + field(:country, :string) + field(:postal_code, :string) + field(:remote, :boolean, default: false) + field(:website, URIType) + field(:ttl, IntervalType, default: %{months: 0, days: 30, secs: 0}) + field(:expired_at, :utc_datetime_usec) + field(:expired?, :boolean, virtual: true) + field(:sponsor_id, :binary_id, virtual: true) + field(:updated_by, :binary_id, virtual: true) + + belongs_to(:author, Member, foreign_key: :created_by) + has_one(:sponsor, through: [:author, :sponsor]) + + has_many(:history_entries, PostHistoryEntry, foreign_key: :id) + + timestamps() + end + + @spec changeset(map(), map()) :: Changeset.t() + def changeset(post, attrs) do + post + |> cast(attrs, @fields) + |> validate_required(@required_fields) + end + + @spec from(Query.t()) :: Query.t() + def from(query \\ __MODULE__) do + query + |> from(as: :post) + |> with_author() + |> with_history_entries() + |> PostHistoryEntry.where_is_currently_valid() + |> order_by([post: p], desc: p.inserted_at) + |> select_merge([post: p, author: a, post_history: ph], %{ + expired?: fragment("? < current_timestamp", p.expired_at), + sponsor_id: a.sponsor_id, + updated_by: ph.created_by + }) + end + + @spec with_author(Query.t()) :: Query.t() + def with_author(query \\ from()) do + if has_named_binding?(query, :author) do + query + else + join( + query, + :left, + [post: p], + a in assoc(p, :author), + as: :author + ) + end + end + + @spec with_history_entries(Query.t()) :: Query.t() + def with_history_entries(query \\ from()) do + if has_named_binding?(query, :post_history) do + query + else + join( + query, + :left, + [post: p], + ph in assoc(p, :history_entries), + as: :post_history + ) + end + end + + @spec where_inserted_in_current_year(Query.t()) :: Query.t() + def where_inserted_in_current_year(query \\ from()) do + where( + query, + [post: p], + fragment("date_part('year', ?) = date_part('year', now())", p.inserted_at) + ) + end + + @spec where_approved(Query.t()) :: Query.t() + def where_approved(query \\ from()) do + where(query, [post: p], p.approved == true) + end + + @spec where_unapproved(Query.t()) :: Query.t() + def where_unapproved(query \\ from()) do + where(query, [post: p], p.approved == false) + end + + @spec where_expired(Query.t()) :: Query.t() + def where_expired(query \\ from()) do + where(query, [post: p], fragment("current_timestamp > ?", p.expired)) + end + + @spec where_fresh(Query.t()) :: Query.t() + def where_fresh(query \\ from()) do + where(query, [post: p], fragment("current_timestamp <= ?", p.expired_at)) + end + + @spec where_id(Query.t(), term()) :: Query.t() + def where_id(query \\ from(), id), do: where(query, id: ^id) + + @spec where_author_id(Query.t(), term()) :: Query.t() + def where_author_id(query \\ from(), id), do: where(query, [post: p], p.created_by == ^id) + + @spec where_owner_id(Query.t(), term()) :: Query.t() + def where_owner_id(query \\ from(), author_or_sponsor_id) do + author_or_sponsor_id = Ecto.UUID.dump!(author_or_sponsor_id) + + query + |> with_author() + |> where( + [author: a], + fragment( + "(? is not null and ? = ?) or ? = ?", + a.sponsor_id, + a.sponsor_id, + ^author_or_sponsor_id, + a.id, + ^author_or_sponsor_id + ) + ) + end + + @spec order_by_sponsor_owner_asc(Query.t()) :: Query.t() + def order_by_sponsor_owner_asc(query \\ from()) do + query + |> with_author() + |> order_by([author: a], + asc: fragment("(case when ? is not null then 1 else 0 end)", a.sponsor_id) + ) + end +end diff --git a/lib/erlef/jobs/post_history_entry.ex b/lib/erlef/jobs/post_history_entry.ex new file mode 100644 index 00000000..ed076ff6 --- /dev/null +++ b/lib/erlef/jobs/post_history_entry.ex @@ -0,0 +1,77 @@ +defmodule Erlef.Jobs.PostHistoryEntry do + @moduledoc false + + use Erlef.Schema + + alias Ecto.Changeset + alias Ecto.Query + alias Erlef.Jobs.IntervalType + alias Erlef.Jobs.TstzRangeType + alias Erlef.Jobs.URIType + alias Erlef.Jobs.Post + + import Ecto.Changeset + import Ecto.Query + + @required_fields [:created_by] + @optional_fields [ + :id, + :title, + :description, + :position_type, + :approved, + :website, + :approved, + :city, + :region, + :country, + :remote, + :deleted_by + ] + @fields @required_fields ++ @optional_fields + + @primary_key {:hid, :binary_id, autogenerate: true} + schema "job_posts_history" do + field(:title, :string) + field(:description, :string) + field(:position_type, Ecto.Enum, values: [:permanent, :contractor]) + field(:approved, :boolean) + field(:city, :string) + field(:region, :string) + field(:country, :string) + field(:postal_code, :string) + field(:remote, :boolean) + field(:website, URIType) + field(:ttl, IntervalType) + + field(:created_by, :binary_id) + field(:deleted_by, :binary_id) + field(:created_at, :utc_datetime) + field(:deleted_at, :utc_datetime) + field(:valid_range, TstzRangeType) + + belongs_to(:post, Post, foreign_key: :id) + end + + @spec changeset(map(), map()) :: Changeset.t() + def changeset(entry, attrs) do + entry + |> cast(attrs, @fields) + |> validate_required(@required_fields) + |> foreign_key_constraint(:id) + end + + @spec from(Query.t()) :: Query.t() + def from(query \\ __MODULE__) do + from(query, as: :post_history) + end + + @spec where_is_currently_valid(Query.t()) :: Query.t() + def where_is_currently_valid(query \\ from()) do + where( + query, + [post_history: ph], + fragment("?::tstzrange @> current_timestamp", ph.valid_range) + ) + end +end diff --git a/lib/erlef/jobs/tstz_range_type.ex b/lib/erlef/jobs/tstz_range_type.ex new file mode 100644 index 00000000..76744c3b --- /dev/null +++ b/lib/erlef/jobs/tstz_range_type.ex @@ -0,0 +1,24 @@ +defmodule Erlef.Jobs.TstzRangeType do + @moduledoc false + + use Ecto.Type + + def type(), do: :tstzrange + + def cast(nil), do: {:ok, nil} + def cast(%Postgrex.Range{} = range), do: {:ok, from_postgrex(range)} + def cast(%{} = range), do: {:ok, range} + def cast(_), do: :error + + def load(nil), do: {:ok, nil} + def load(%Postgrex.Range{} = range), do: {:ok, from_postgrex(range)} + def load(_), do: :error + + def dump(nil), do: {:ok, nil} + def dump(%{} = range), do: {:ok, to_postgrex(range)} + def dump(_), do: :error + + defp from_postgrex(%Postgrex.Range{} = range), do: Map.from_struct(range) + + defp to_postgrex(%{} = range), do: struct!(Postgrex.Range, range) +end diff --git a/lib/erlef/jobs/uri_type.ex b/lib/erlef/jobs/uri_type.ex new file mode 100644 index 00000000..a0b61876 --- /dev/null +++ b/lib/erlef/jobs/uri_type.ex @@ -0,0 +1,31 @@ +defmodule Erlef.Jobs.URIType do + @moduledoc false + + use Ecto.Type + + def type(), do: :string + + def cast(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{scheme: nil} -> + {:error, message: "is missing a scheme (e.g. https)"} + + %URI{host: nil} -> + {:error, message: "is missing a host"} + + %URI{host: host} = uri -> + case :inet.gethostbyname(String.to_charlist(host)) do + {:ok, _} -> {:ok, uri} + {:error, _} -> {:error, message: "has an invalid host"} + end + end + end + + def cast(%URI{} = uri), do: {:ok, uri} + + def load(uri), do: {:ok, URI.parse(uri)} + + def dump(%URI{} = uri), do: {:ok, URI.to_string(uri)} + + def dump(_), do: :error +end diff --git a/lib/erlef/mailer.ex b/lib/erlef/mailer.ex index c0322d90..c8651fd3 100644 --- a/lib/erlef/mailer.ex +++ b/lib/erlef/mailer.ex @@ -1,18 +1,6 @@ defmodule Erlef.Mailer do - @moduledoc """ - Erlef.Mailer - """ + @moduledoc false - use Swoosh.Mailer, otp_app: :erlef - - def send(email) do - deliver(email, config()) - end - - defp config do - base_config = Application.get_env(:erlef, __MODULE__) - user = System.get_env("SMTP_USER") - passwd = System.get_env("SMTP_PASSWORD") - Keyword.merge(base_config, username: user, password: passwd) - end + use Swoosh.Mailer, + otp_app: :erlef end diff --git a/lib/erlef/members/notifications.ex b/lib/erlef/members/notifications.ex index a191682a..a599ba4b 100644 --- a/lib/erlef/members/notifications.ex +++ b/lib/erlef/members/notifications.ex @@ -1,9 +1,13 @@ defmodule Erlef.Members.Notifications do @moduledoc false + alias Erlef.Accounts + alias Erlef.Accounts.Member + import Swoosh.Email - @type notification_type() :: :new_event_submitted | :new_event_approved + @type notification_type() :: + :new_event_submitted | :new_event_approved | :new_job_post_submitted @type params() :: map() @@ -44,4 +48,39 @@ defmodule Erlef.Members.Notifications do |> subject("Your submitted event has been approved!") |> text_body(msg) end + + def new_job_post_submission(member_id) do + %Member{name: name, email: email} = Accounts.get_member!(member_id) + + msg = """ + Thanks for submitting a new event, we appreciate your involvement in our community. + An admin will approve the event for display shortly. Once approved you will get an email stating so. + + Thanks + + The Erlang Ecosystem Foundation + """ + + new() + |> to({name, email}) + |> from({"Erlef Notifications", "notifications@erlef.org"}) + |> subject("Your submitted a new job post") + |> text_body(msg) + end + + def job_post_approval(member_id, post_title) do + %Member{name: name, email: email} = Accounts.get_member!(member_id) + + msg = """ + Your job post "#{post_title}" has been approved by an administrator. + + The Erlang Ecosystem Foundation + """ + + new() + |> to({name, email}) + |> from({"Erlef Notifications", "notifications@erlef.org"}) + |> subject("Your submitted a new job post") + |> text_body(msg) + end end diff --git a/lib/erlef/outbox/email.ex b/lib/erlef/outbox/email.ex new file mode 100644 index 00000000..1a2ccf2d --- /dev/null +++ b/lib/erlef/outbox/email.ex @@ -0,0 +1,19 @@ +defmodule Erlef.Outbox.Email do + @moduledoc """ + A generalized Oban worker for sending emails. + """ + + use Oban.Worker, queue: :email + + alias Erlef.Mailer + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"module" => m, "fun" => f, "args" => args}}) do + module = String.to_existing_atom(m) + function = String.to_existing_atom(f) + + module + |> apply(function, args) + |> Mailer.deliver() + end +end diff --git a/lib/erlef/repo.ex b/lib/erlef/repo.ex index a2e89037..70f489be 100644 --- a/lib/erlef/repo.ex +++ b/lib/erlef/repo.ex @@ -3,15 +3,38 @@ defmodule Erlef.Repo do otp_app: :erlef, adapter: Ecto.Adapters.Postgres - import Ecto.Query + @spec count(Ecto.Schema.t(), Keyword.t()) :: integer() | nil + def count(schema, opts \\ []) do + aggregate(schema, :count, opts) + end + + def transact(fun, opts \\ []) when is_function(fun) do + transaction( + fn repo -> + {:arity, arity} = Function.info(fun, :arity) + + result = + case arity do + 0 -> + fun.() + + 1 -> + fun.(repo) + + other -> + raise ArgumentError, + "A function with arity 0 or 1 expected but got one with arity #{other}." + end - @spec count(Ecto.Schema.t()) :: integer() | nil - def count(schema) do - q = - from(s in schema, - select: count(s.id) - ) + case result do + {:ok, result} -> + result - one(q) + {:error, error} -> + rollback(error) + end + end, + opts + ) end end diff --git a/lib/erlef_web/controllers/admin/job_controller.ex b/lib/erlef_web/controllers/admin/job_controller.ex new file mode 100644 index 00000000..ebcce36e --- /dev/null +++ b/lib/erlef_web/controllers/admin/job_controller.ex @@ -0,0 +1,31 @@ +defmodule ErlefWeb.Admin.JobController do + use ErlefWeb, :controller + + action_fallback ErlefWeb.FallbackController + + alias Erlef.Jobs + + def index(conn, _params) do + posts = Jobs.list_unapproved_posts() + + render(conn, unapproved_job_posts: posts) + end + + def show(conn, %{"id" => id}) do + post = Jobs.get_post!(id) + + render(conn, changeset: Jobs.change_post(post), post: post) + end + + def approve(conn, %{"id" => id}) do + admin = conn.assigns.current_user + post = Jobs.get_post!(id) + + case Jobs.approve_post(admin, post) do + {:ok, _} -> + conn + |> redirect(to: Routes.admin_job_path(conn, :index)) + |> halt() + end + end +end diff --git a/lib/erlef_web/controllers/job_controller.ex b/lib/erlef_web/controllers/job_controller.ex new file mode 100644 index 00000000..edc29a0c --- /dev/null +++ b/lib/erlef_web/controllers/job_controller.ex @@ -0,0 +1,130 @@ +defmodule ErlefWeb.JobController do + use ErlefWeb, :controller + + alias Ecto.Changeset + alias Erlef.Jobs + alias Erlef.Groups + alias __MODULE__.CreatePostForm + alias __MODULE__.UpdatePostForm + + plug :post_quota_guard when action in [:new, :create] + plug :authorize_post when action in [:edit, :update, :delete] + + action_fallback ErlefWeb.FallbackController + + def index(conn, _params) do + posts = Jobs.list_posts() + + render(conn, "index.html", posts: posts) + end + + def new(conn, _params) do + changeset = CreatePostForm.changeset() + + render(conn, "new.html", changeset: changeset) + end + + def create(conn, %{"post" => post_params}) do + current_user = conn.assigns.current_user + + normalization = + %CreatePostForm{} + |> CreatePostForm.changeset(post_params) + |> Changeset.apply_action(:insert) + + with {:ok, form} <- normalization, + post_params = Map.from_struct(form), + {:ok, post} <- Jobs.create_post(current_user, post_params) do + conn + |> put_flash(:info, "Post created successfully.") + |> redirect(to: Routes.job_path(conn, :show, post.id)) + else + {:error, %Changeset{} = changeset} -> + render(conn, "new.html", changeset: changeset) + end + end + + def show(conn, %{"id" => id}) do + post = Jobs.get_post!(id) + current_user = conn.assigns.current_user + + if post.approved || (current_user && Jobs.owns_post?(current_user, post)) do + %{sponsor_id: sponsor_id} = post + sponsor = sponsor_id && Groups.get_sponsor!(sponsor_id) + + render(conn, "show.html", post: post, sponsor: sponsor) + else + conn + |> put_status(404) + |> put_view(ErlefWeb.ErrorView) + |> render("404.html") + end + end + + def edit(conn, _) do + post = conn.assigns.post + changeset = UpdatePostForm.for_post(post) + + render(conn, "edit.html", post: post, changeset: changeset) + end + + def update(conn, %{"post" => post_params}) do + %{current_user: current_user, post: post} = conn.assigns + + normalization = + %UpdatePostForm{} + |> UpdatePostForm.changeset(post_params) + |> Changeset.apply_action(:update) + + with {:ok, form} <- normalization, + post_params = Map.from_struct(form), + {:ok, updated} <- Jobs.update_post(current_user, post, post_params) do + conn + |> put_flash(:info, "Post updated successfully.") + |> redirect(to: Routes.job_path(conn, :show, updated.id)) + else + {:error, %Changeset{} = changeset} -> + render(conn, "edit.html", post: post, changeset: changeset) + end + end + + def delete(conn, _) do + %{current_user: current_user, post: post} = conn.assigns + {:ok, _} = Jobs.delete_post(current_user, post) + + conn + |> put_flash(:info, "Post deleted successfully") + |> redirect(to: redirection_target(conn)) + end + + defp redirection_target(conn) do + conn.params["redirect_to"] || Routes.job_path(conn, :index) + end + + defp post_quota_guard(conn, _) do + current_user = conn.assigns.current_user + + if Jobs.can_create_post?(current_user) do + conn + else + conn + |> put_flash(:error, "You've reached your post quota.") + |> redirect(to: "/") + |> halt() + end + end + + defp authorize_post(conn, _) do + post = Jobs.get_post!(conn.params["id"]) + current_user = conn.assigns.current_user + + if Jobs.owns_post?(current_user, post) do + assign(conn, :post, post) + else + conn + |> put_status(404) + |> put_view(ErlefWeb.ErrorView) + |> render("404.html") + end + end +end diff --git a/lib/erlef_web/controllers/job_controller/create_post_form.ex b/lib/erlef_web/controllers/job_controller/create_post_form.ex new file mode 100644 index 00000000..d0b4c800 --- /dev/null +++ b/lib/erlef_web/controllers/job_controller/create_post_form.ex @@ -0,0 +1,38 @@ +defmodule ErlefWeb.JobController.CreatePostForm do + @moduledoc """ + An embedded Ecto schema for validation and normalization of a form for post + creation. + """ + + use Ecto.Schema + + alias Erlef.Jobs.URIType + + import Ecto.Changeset + + @required_fields [:title, :description, :position_type, :website] + @optional_fields [:approved, :city, :region, :country, :remote, :days_to_live] + @fields @required_fields ++ @optional_fields + + @primary_key false + embedded_schema do + field(:title, :string) + field(:description, :string) + field(:position_type, Ecto.Enum, values: [:permanent, :contractor]) + field(:approved, :boolean, default: false) + field(:city, :string) + field(:region, :string) + field(:country, :string) + field(:postal_code, :string) + field(:remote, :boolean, default: false) + field(:website, URIType) + field(:days_to_live, :integer, default: 30) + end + + def changeset(request \\ %__MODULE__{}, attrs \\ %{}) do + request + |> cast(attrs, @fields) + |> validate_required(@required_fields) + |> validate_number(:days_to_live, greater_than: 0) + end +end diff --git a/lib/erlef_web/controllers/job_controller/update_post_form.ex b/lib/erlef_web/controllers/job_controller/update_post_form.ex new file mode 100644 index 00000000..c490c245 --- /dev/null +++ b/lib/erlef_web/controllers/job_controller/update_post_form.ex @@ -0,0 +1,49 @@ +defmodule ErlefWeb.JobController.UpdatePostForm do + @moduledoc """ + An embedded Ecto schema for validation and normalization of a form for post + updates. + """ + + use Ecto.Schema + + alias Erlef.Jobs.URIType + alias Erlef.Jobs.Post + + import Ecto.Changeset + + @required_fields [:title, :description, :position_type, :website] + @optional_fields [:approved, :city, :region, :country, :remote] + @fields @required_fields ++ @optional_fields + + @primary_key false + embedded_schema do + field(:title, :string) + field(:description, :string) + field(:position_type, Ecto.Enum, values: [:permanent, :contractor]) + field(:approved, :boolean, default: false) + field(:city, :string) + field(:region, :string) + field(:country, :string) + field(:postal_code, :string) + field(:remote, :boolean, default: false) + field(:website, URIType) + end + + @doc """ + Returns a changeset for updating a particular post. + """ + @spec for_post(Post.t()) :: term() + def for_post(%Post{} = post) do + # This is the place where a mapping between the post and this module's + # struct should be performed. + post_map = Map.from_struct(post) + + changeset(%__MODULE__{}, post_map) + end + + def changeset(request \\ %__MODULE__{}, attrs \\ %{}) do + request + |> cast(attrs, @fields) + |> validate_required(@required_fields) + end +end diff --git a/lib/erlef_web/controllers/members/profile_controller.ex b/lib/erlef_web/controllers/members/profile_controller.ex index c3d3a93a..22140de0 100644 --- a/lib/erlef_web/controllers/members/profile_controller.ex +++ b/lib/erlef_web/controllers/members/profile_controller.ex @@ -2,18 +2,22 @@ defmodule ErlefWeb.Members.ProfileController do use ErlefWeb, :controller alias Erlef.Members + alias Erlef.Jobs def show(conn, _) do + current_user = conn.assigns.current_user conference_perks = Application.get_env(:erlef, :conference_perks) - has_email_request = Members.has_email_request?(conn.assigns.current_user) - email_request = Members.get_email_request_by_member(conn.assigns.current_user) + has_email_request = Members.has_email_request?(current_user) + email_request = Members.get_email_request_by_member(current_user) + posts = Jobs.list_posts_by_author_id(current_user.id) render(conn, has_email_request: has_email_request, email_request: email_request, conference_perks: conference_perks, video_perks_on: true, - conference_perks_on: false + conference_perks_on: false, + posts: posts ) end end diff --git a/lib/erlef_web/controllers/stipend_controller.ex b/lib/erlef_web/controllers/stipend_controller.ex index ef0dcf6f..38be1f06 100644 --- a/lib/erlef_web/controllers/stipend_controller.ex +++ b/lib/erlef_web/controllers/stipend_controller.ex @@ -1,5 +1,6 @@ defmodule ErlefWeb.StipendController do use ErlefWeb, :controller + action_fallback ErlefWeb.FallbackController def index(conn, _params) do @@ -23,8 +24,14 @@ defmodule ErlefWeb.StipendController do case Erlef.StipendProposal.from_map(Map.put(params, "files", files)) do {:ok, proposal} -> - Erlef.StipendMail.submission(proposal) |> Erlef.Mailer.send() - Erlef.StipendMail.submission_copy(proposal) |> Erlef.Mailer.send() + proposal + |> Erlef.StipendMail.submission() + |> Erlef.Mailer.deliver() + + proposal + |> Erlef.StipendMail.submission_copy() + |> Erlef.Mailer.deliver() + render(conn) {:error, errs} -> diff --git a/lib/erlef_web/router.ex b/lib/erlef_web/router.ex index 9dd422f1..4c679c9c 100644 --- a/lib/erlef_web/router.ex +++ b/lib/erlef_web/router.ex @@ -112,6 +112,14 @@ defmodule ErlefWeb.Router do get "/archived", BlogController, :index_archived end + scope "/jobs" do + pipe_through [:session_required] + + resources "/", JobController, except: [:index, :show] + end + + resources "/jobs", JobController, only: [:index, :show] + # NOTE: News routes are still in place for links that may be out there. # Please use blog routes. get "/news", BlogController, :index, as: :news @@ -190,6 +198,10 @@ defmodule ErlefWeb.Router do end resources "/events", EventController, only: [:index, :show] + + resources "/jobs", JobController, only: [:index, :show] + put "/jobs/:id", JobController, :approve + resources "/email_requests", EmailRequestController, only: [:index, :show] post "/email_requests/assign", EmailRequestController, :assign post "/email_requests/complete", EmailRequestController, :complete diff --git a/lib/erlef_web/templates/admin/dashboard/index.html.eex b/lib/erlef_web/templates/admin/dashboard/index.html.eex index 7f71272c..ca8f09ac 100644 --- a/lib/erlef_web/templates/admin/dashboard/index.html.eex +++ b/lib/erlef_web/templates/admin/dashboard/index.html.eex @@ -1,5 +1,12 @@
# | +Title | +
---|---|
<%= link(post.id, to: Routes.admin_job_path(@conn, :show, post.id)) %> | +<%= post.title %> | +
Oops, something went wrong! Please check the errors below.
+Title | +Sponsored | +Published at | +
---|---|---|
<%= link(post.title, to: Routes.job_path(@conn, :show, post.id)) %> | +<%= !!post.sponsor_id %> | +<%= Calendar.strftime(post.inserted_at, "%m/%d/%Y") %> | +
Oops, something went wrong! Please check the errors below. +
+Title | +Approved | +Actions | +
---|---|---|
<%= link(post.title, to: Routes.job_path(@conn, :show, post.id)) %> | +<%= post.approved %> | ++ <%= button(raw(""), to: Routes.job_path(@conn, :edit, post.id), method: :get, class: "btn btn-sm btn-primary") %> + <%= button(raw(""), to: Routes.job_path(@conn, :delete, post.id, redirect_to: redirect_to), method: :delete, data: [confirm: "Are you sure?"], class: "btn btn-sm btn-danger") %> + | +
You submitted a request for an erlef.org address. Details are below :
-Type | -Status | -Requested name | -Submitted on | -
---|---|---|---|
<%= humanize(@email_request.type) %> | -<%= humanize(@email_request.status) %> | -<%= @email_request.username %> | -<%= to_date_string(@email_request.inserted_at) %> | -
You submitted a request for an erlef.org address. Details are below :
+Type | +Status | +Requested name | +Submitted on | +
---|---|---|---|
<%= humanize(@email_request.type) %> | +<%= humanize(@email_request.status) %> | +<%= @email_request.username %> | +<%= to_date_string(@email_request.inserted_at) %> | +
Request an invite to the Erlef slack workspace
<%= if @current_user.has_requested_slack_invite do %> - You previously requested an invite to Erlef slack on <%= long_date(@current_user.requested_slack_invite_at) %>. If you did not receive an invite or you are having related problems, please contact infra@erlef.org describing the issue you having. + You previously requested an invite to Erlef slack on <%= long_date(@current_user.requested_slack_invite_at) %>. If + you did not receive an invite or you are having related problems, please contact infra@erlef.org describing the issue you having. <% else %> - <%= link("Send me a slack invite", to: Routes.members_slack_invite_path(@conn, :create), class: "btn btn-primary", method: :post) %> + <%= link("Send me a slack invite", to: Routes.members_slack_invite_path(@conn, :create), class: "btn btn-primary", method: :post) %> <% end %>From time to time we are able to offer our members discounts on tickets to conferences and other related perks.
+From time to time we are able to offer our members discounts on tickets to conferences and other related perks. +
<% code_beam_perks = @conference_perks.code_beam_v_2021 %> - <%= if Date.compare(code_beam_perks.ends, Date.utc_today()) in [:gt, :eq] do %> -Get a 30% discount on Code Beam V 2021 tickets here.
- -We are able to offer steaming video access to members for certain events at Code BEAM V America 2021, regardless of whether you have a ticket or not. Check back here starting the day of the conference up until the end of the conference for links to watch the foundation keynote live and Birds of a Feather sessions
-Note: We trust our members and as such we trust you will not share the links below with the general public.
- -Click here to watch the foundation keynote live
- -Click here to watch and participate in Birds of Feather
- <% else %> -Stay tuned. We will be providing members access to certain key events at Code BEAM 2021 V - America
- <% end %> + <%= if Date.compare(code_beam_perks.ends, Date.utc_today()) in [:gt, :eq] do %> +Get a 30% discount on Code Beam V 2021 tickets here.
+ +We are able to offer steaming video access to members for certain events at Code BEAM V America 2021, + regardless of whether you have a ticket or not. Check back here starting the day of the conference up until the + end of the conference for links to watch the foundation keynote live and Birds of a Feather sessions
+Note: We trust our members and as such we trust you will not share the links below with the general + public.
+ +Click here to watch the foundation keynote live
+ +Click here to watch and participate in Birds of Feather
+ <% else %> +Stay tuned. We will be providing members access to certain key events at Code BEAM 2021 V - America
+ <% end %> + <% end %> <% end %> - <% end %> -- Click here - to view more manage your memberships details and subscription. + Click here + to view more manage your memberships details and subscription.