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..87f80640 100644 --- a/config/test.exs +++ b/config/test.exs @@ -14,9 +14,6 @@ config :erlef, :env, :test config :erlef, Erlef.Mailer, adapter: Swoosh.Adapters.Test -config :erlef, :wild_apricot_base_api_url, "http://127.0.0.1:9999" -config :erlef, :wild_apricot_base_auth_url, "http://127.0.0.1:9999" - # Print only warnings and errors during test config :logger, level: :warn @@ -56,4 +53,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/external.ex b/lib/erlef/accounts/external.ex index 1a2ecdff..aa61f6dc 100644 --- a/lib/erlef/accounts/external.ex +++ b/lib/erlef/accounts/external.ex @@ -1,9 +1,9 @@ defmodule Erlef.Accounts.External do @moduledoc """ - Erlef.Accounts.External provides a schema and helper functions for converting external accounts to an Erlef app - member or member attributes. + Erlef.Accounts.External provides a schema and helper functions for converting external accounts to an Erlef app + member or member attributes. - While this module is called External to leave it open, the only supported external account is Wildapricot. + While this module is called External to leave it open, the only supported external account is Wildapricot. The module is intended to serve as boundary between the accounts context and wildapricot data structures. @@ -26,7 +26,7 @@ defmodule Erlef.Accounts.External do # The following is used to map wildapricot fields to normalized atoms for use # within the app. Likewise,it used to construct lists that are in turn used to map this struct back to - # the wildapricot format. Note that the structure below is flat, where as the wildapricot data + # the wildapricot format. Note that the structure below is flat, where as the wildapricot data # structure is not, in particular the "FieldValues" section of a contact is a list of maps. # # Per the above we annotate fields that are part of the "FieldValues" list. @@ -51,8 +51,8 @@ defmodule Erlef.Accounts.External do "has_email_box" => {:has_email_box, :field_value} } - # We build these lists so that we don't have to compute them at runtime. - # They are used for mapping Member fields back to wildapricot fields and forms. + # We build these lists so that we don't have to compute them at runtime. + # They are used for mapping Member fields back to wildapricot fields and forms. @field_value_key_map Enum.filter(@str_key_map, fn {_k, v} -> is_tuple(v) end) @membership_level_str_map %{ @@ -61,6 +61,7 @@ defmodule Erlef.Accounts.External do "Lifetime Supporting Membership" => :lifetime, "Board" => :board, "Fellow" => :fellow, + "Sponsored Membership" => :sponsored, "Managing and Contributing" => :contributor } diff --git a/lib/erlef/accounts/member.ex b/lib/erlef/accounts/member.ex index 5b4d9024..7af06a28 100644 --- a/lib/erlef/accounts/member.ex +++ b/lib/erlef/accounts/member.ex @@ -1,17 +1,20 @@ 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. - Members are a "concrete" representation of an associated external resource, namely wildapricot, + Members are a "concrete" representation of an associated external resource, namely wildapricot, and as such it shou ld be duly that this application is not the source of truth of members. - The schema allows us to cache member attributes, such as the member's name. This is useful in the case of - quite wildapricot per their strict api rate limits. Additionally, this allows us to properly associate and - constraint other schemas within the system to a member; as well as keeping the rest of the application - completely ignorant in regards to wildapricot + The schema allows us to cache member attributes, such as the member's name. This is useful in the case of + quite wildapricot per their strict api rate limits. Additionally, this allows us to properly associate and + constraint other schemas within the system to a member; as well as keeping the rest of the application + completely ignorant in regards to wildapricot See `Erlef.Accounts.External` for details on how fields are mapped between the two resources. """ @@ -21,11 +24,13 @@ defmodule Erlef.Accounts.Member do :lifetime, :board, :fellow, - :contributor + :contributor, + :sponsored ] @membership_level_str_map %{ "Basic Membership" => :basic, + "Sponsored Membership" => :sponsored, "Annual Supporting Membership" => :annual, "Lifetime Supporting Membership" => :lifetime, "Board" => :board, @@ -59,7 +64,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 +94,8 @@ defmodule Erlef.Accounts.Member do :requested_slack_invite_at, :suspended_member, :terms_of_use_accepted, - :deactivated_at + :deactivated_at, + :sponsor_id ] @required_fields [ @@ -116,10 +127,16 @@ defmodule Erlef.Accounts.Member do |> validate_email(:erlef_email_address) end - def by_external_id(id) do - from(m in __MODULE__, - where: fragment("(external->>'id' = ?)", ^id) - ) + def from(query \\ __MODULE__) do + from(query, as: :member) + end + + def where_sponsor_id(query \\ from(), sponsor_id) do + where(query, [member: m], m.sponsor_id == ^sponsor_id) + end + + def by_external_id(query \\ from(), external_id) do + where(query, [member: m], fragment("(?->>'id' = ?)", m.external, ^external_id)) end def is_paying?(%Member{membership_level: level}) when is_atom(level) do 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..29e58466 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 post was successfully 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..c2ecb6d8 --- /dev/null +++ b/lib/erlef/jobs.ex @@ -0,0 +1,401 @@ +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 + 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} -> + post = + post + |> Map.put(:sponsor_id, member.sponsor_id) + |> Map.put(:updated_by, post.created_by) + + {:ok, post} + + 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} + 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 + 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{} = member) do + base_query = + case member do + %Member{id: id, sponsor_id: nil} -> + Post.where_author_id(id) + + %Member{sponsor_id: id} -> + Post.with_author() + |> Member.where_sponsor_id(id) + end + + base_query + |> 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." + + # Works like `Ecto.Repo.transaction` but only with functions which return a + # result tuple. + # + # Removes the need to destructure the error tuple, and rollback with it in + # order to return a tuple result from the transaction. + @spec transact((fun() -> {:ok | :error, term()}), Keyword.t()) :: {:ok | :error, term()} + defp transact(fun, opts \\ []) when is_function(fun) do + Repo.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 + + case result do + {:ok, result} -> + result + + {:error, error} -> + Repo.rollback(error) + end + end, + opts + ) + end +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..5d75110f --- /dev/null +++ b/lib/erlef/jobs/post.ex @@ -0,0 +1,139 @@ +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, member: a, post_history: ph], %{ + expired?: fragment("? < current_timestamp", p.expired_at), + sponsor_id: a.sponsor_id, + updated_by: ph.created_by + }) + end + + def with_author(query \\ from()) do + if has_named_binding?(query, :member) do + query + else + join( + query, + :left, + [post: p], + a in assoc(p, :author), + as: :member + ) + 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 order_by_sponsor_owner_asc(Query.t()) :: Query.t() + def order_by_sponsor_owner_asc(query \\ from()) do + query + |> with_author() + |> order_by([member: 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..45d007c6 100644 --- a/lib/erlef/repo.ex +++ b/lib/erlef/repo.ex @@ -3,15 +3,8 @@ defmodule Erlef.Repo do otp_app: :erlef, adapter: Ecto.Adapters.Postgres - import Ecto.Query - - @spec count(Ecto.Schema.t()) :: integer() | nil - def count(schema) do - q = - from(s in schema, - select: count(s.id) - ) - - one(q) + @spec count(Ecto.Schema.t(), Keyword.t()) :: integer() | nil + def count(schema, opts \\ []) do + aggregate(schema, :count, opts) end end diff --git a/lib/erlef/schema.ex b/lib/erlef/schema.ex index 82dc231a..e5395d63 100644 --- a/lib/erlef/schema.ex +++ b/lib/erlef/schema.ex @@ -26,7 +26,7 @@ defmodule Erlef.Schema do import Ecto import Ecto.Changeset import Erlef.Schema - import Ecto.Query, only: [from: 1, from: 2] + import Ecto.Query @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id 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/session_controller.ex b/lib/erlef_web/controllers/session_controller.ex index 5ece33e4..2c533d5c 100644 --- a/lib/erlef_web/controllers/session_controller.ex +++ b/lib/erlef_web/controllers/session_controller.ex @@ -1,5 +1,6 @@ defmodule ErlefWeb.SessionController do use ErlefWeb, :controller + action_fallback ErlefWeb.FallbackController @spec show(Plug.Conn.t(), map()) :: no_return() 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/plugs/session.ex b/lib/erlef_web/plugs/session.ex index f86a6a1b..3c69f0fc 100644 --- a/lib/erlef_web/plugs/session.ex +++ b/lib/erlef_web/plugs/session.ex @@ -2,14 +2,19 @@ defmodule ErlefWeb.Plug.Session do @moduledoc """ Erlef.Plug.Session - handles session refresh and expiration """ - import Plug.Conn + require Logger def init(opts), do: opts def call(conn, _opts) do - case session(conn) do + session = + conn + |> get_session("member_session") + |> Erlef.Session.build() + + case session do %Erlef.Session{} = session -> set_session(conn, session) @@ -32,16 +37,14 @@ defmodule ErlefWeb.Plug.Session do end defp set_session(conn, session) do - case Erlef.Session.expired?(session) do - true -> - purge_session(conn, session) - - false -> - conn - |> maybe_refresh_token(session) - |> assign(:current_session, session) - |> assign(:current_user, session.member) - |> maybe_assign_volunteer(session.member) + if Erlef.Session.expired?(session) do + purge_session(conn, session) + else + conn + |> maybe_refresh_token(session) + |> assign(:current_session, session) + |> assign(:current_user, session.member) + |> maybe_assign_volunteer(session.member) end end @@ -75,10 +78,6 @@ defmodule ErlefWeb.Plug.Session do end end - defp session(conn) do - get_session(conn, "member_session") |> Erlef.Session.build() - end - defp store_session(conn, session) do conn |> configure_session(renew: true) 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. +
+