├── Procfile ├── .tool-versions ├── assets ├── .babelrc ├── static │ ├── favicon.ico │ ├── images │ │ ├── logo.png │ │ ├── phoenix.png │ │ ├── logo.svg │ │ └── sponsors │ │ │ ├── learn-elixir-white.svg │ │ │ ├── learn-elixir.svg │ │ │ └── appsignal.svg │ ├── icons │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ └── safari-pinned-tab.svg │ ├── robots.txt │ ├── browserconfig.xml │ └── manifest.json ├── css │ ├── _config.scss │ ├── components │ │ ├── _login.scss │ │ ├── _section.scss │ │ ├── _layout.scss │ │ ├── _about.scss │ │ ├── _footer.scss │ │ ├── _offer.scss │ │ ├── _hero.scss │ │ └── _navbar.scss │ ├── sections │ │ ├── offers-show.scss │ │ └── offers-index.scss │ └── app.scss ├── postcss.config.js ├── js │ ├── app │ │ ├── notifications.js │ │ ├── navbar.js │ │ └── particles.js │ ├── app.js │ └── socket.js ├── package.json └── webpack.config.js ├── lib ├── elixir_jobs_web │ ├── templates │ │ ├── layout │ │ │ ├── email.text.eex │ │ │ ├── shared │ │ │ │ ├── _alerts.html.eex │ │ │ │ ├── _analytics.html.eex │ │ │ │ ├── _hero.html.eex │ │ │ │ ├── _cookies.html.eex │ │ │ │ ├── _footer.html.eex │ │ │ │ ├── _navbar.html.eex │ │ │ │ └── _head.html.eex │ │ │ ├── email.html.eex │ │ │ └── app.html.eex │ │ ├── admin │ │ │ └── offer │ │ │ │ ├── edit.html.eex │ │ │ │ ├── _pagination.html.eex │ │ │ │ ├── index_published.html.eex │ │ │ │ └── index_unpublished.html.eex │ │ ├── offer │ │ │ ├── index │ │ │ │ ├── _no-offer.html.eex │ │ │ │ ├── _pagination.html.eex │ │ │ │ └── _filters.html.eex │ │ │ ├── preview.html.eex │ │ │ ├── index.html.eex │ │ │ ├── show.html.eex │ │ │ ├── new.html.eex │ │ │ ├── show │ │ │ │ ├── _share.html.eex │ │ │ │ └── _administration.html.eex │ │ │ ├── rss.xml.eex │ │ │ ├── _offer_header.html.eex │ │ │ └── _form.html.eex │ │ ├── email │ │ │ ├── offer_created.text.eex │ │ │ └── offer_created.html.eex │ │ ├── auth │ │ │ └── new.html.eex │ │ ├── error │ │ │ ├── 404.html.eex │ │ │ └── 500.html.eex │ │ ├── sitemap │ │ │ └── sitemap.xml.eex │ │ └── page │ │ │ └── about.html.eex │ ├── views │ │ ├── auth_view.ex │ │ ├── page_view.ex │ │ ├── sitemap_view.ex │ │ ├── admin │ │ │ └── offer_view.ex │ │ ├── email_view.ex │ │ ├── error_view.ex │ │ ├── layout_view.ex │ │ └── offer_view.ex │ ├── mailer.ex │ ├── controllers │ │ ├── page_controller.ex │ │ ├── sitemap_controller.ex │ │ ├── auth_controller.ex │ │ └── admin │ │ │ └── offer_controller.ex │ ├── plugs │ │ ├── guardian_pipeline.ex │ │ └── current_user.ex │ ├── guardian.ex │ ├── gettext.ex │ ├── helpers │ │ ├── view_helper.ex │ │ ├── date_helper.ex │ │ ├── error_helper.ex │ │ ├── humanize_helper.ex │ │ ├── seo_helper.ex │ │ └── microdata_helper.ex │ ├── channels │ │ └── user_socket.ex │ ├── email.ex │ ├── telegram.ex │ ├── twitter.ex │ ├── endpoint.ex │ └── router.ex ├── elixir_jobs.ex ├── elixir_jobs │ ├── repo.ex │ ├── accounts │ │ ├── queries │ │ │ └── admin.ex │ │ ├── services │ │ │ └── authenticate_admin.ex │ │ ├── accounts.ex │ │ ├── schemas │ │ │ └── admin.ex │ │ └── managers │ │ │ └── admin.ex │ ├── release.ex │ ├── core │ │ ├── core.ex │ │ ├── fields │ │ │ ├── job_place.ex │ │ │ └── job_type.ex │ │ ├── queries │ │ │ └── offer.ex │ │ ├── schemas │ │ │ └── offer.ex │ │ └── managers │ │ │ └── offer.ex │ └── application.ex └── elixir_jobs_web.ex ├── test ├── test_helper.exs ├── elixir_jobs_web │ ├── views │ │ ├── page_view_test.exs │ │ └── layout_view_test.exs │ └── controllers │ │ ├── page_controller_test.exs │ │ └── emails │ │ └── emails_test.exs ├── support │ ├── factories │ │ ├── accounts │ │ │ └── admin.ex │ │ ├── core │ │ │ └── offer.ex │ │ └── base.ex │ ├── factory.ex │ ├── channel_case.ex │ ├── conn_case.ex │ └── data_case.ex └── elixir_jobs │ ├── accounts │ ├── services │ │ └── authenticate_admin_test.exs │ ├── schemas │ │ └── admin_test.exs │ └── managers │ │ └── admin_test.exs │ └── core │ └── fields │ ├── job_place.exs │ └── job_type.exs ├── priv ├── repo │ ├── migrations │ │ ├── 20200208134001_change_offer_summary.exs │ │ ├── 20171001195406_add_summary_to_offers.exs │ │ ├── 20170913192410_add_slug_to_offers.exs │ │ ├── 20171002122508_change_description_character_limit_offers_table.exs │ │ ├── 20180504222508_change_limit_of_offers_summary.exs │ │ ├── 20170911144639_add_enums_to_offers.exs │ │ ├── 20190824101839_normalise_enum_fields.exs │ │ ├── 20170914194826_create_admins.exs │ │ ├── 20190824082508_change_timestamps.exs │ │ ├── 20200208134019_add_contact_email_to_offers.exs │ │ └── 20170911140505_create_offers.exs │ └── seeds.exs └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ └── errors.pot ├── config ├── test.secret.example.exs ├── test.secret.ci.exs ├── dev.secret.example.exs ├── test.exs ├── prod.exs ├── config.exs ├── dev.exs ├── runtime.exs └── staging.exs ├── CHANGELOG.md ├── fly.toml ├── LICENSE ├── .formatter.exs ├── .dockerignore ├── CODE_OF_CONDUCT.md ├── README.md ├── .github └── workflows │ └── elixir.yml ├── mix.exs ├── Dockerfile └── .gitignore /Procfile: -------------------------------------------------------------------------------- 1 | web: mix phx.server 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.15.4 2 | erlang 26.0.2 3 | nodejs 18.16.1 4 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/layout/email.text.eex: -------------------------------------------------------------------------------- 1 | <%= @inner_content %> 2 | 3 | - Elixir Jobs. 4 | -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odarriba/elixir_jobs/HEAD/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odarriba/elixir_jobs/HEAD/assets/static/images/logo.png -------------------------------------------------------------------------------- /lib/elixir_jobs_web/views/auth_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.AuthView do 2 | use ElixirJobsWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.PageView do 2 | use ElixirJobsWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /assets/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odarriba/elixir_jobs/HEAD/assets/static/images/phoenix.png -------------------------------------------------------------------------------- /lib/elixir_jobs_web/views/sitemap_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.SitemapView do 2 | use ElixirJobsWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Faker.start() 3 | 4 | Ecto.Adapters.SQL.Sandbox.mode(ElixirJobs.Repo, :manual) 5 | -------------------------------------------------------------------------------- /assets/static/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odarriba/elixir_jobs/HEAD/assets/static/icons/favicon-16x16.png -------------------------------------------------------------------------------- /assets/static/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odarriba/elixir_jobs/HEAD/assets/static/icons/favicon-32x32.png -------------------------------------------------------------------------------- /assets/static/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odarriba/elixir_jobs/HEAD/assets/static/icons/mstile-150x150.png -------------------------------------------------------------------------------- /lib/elixir_jobs_web/views/admin/offer_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Admin.OfferView do 2 | use ElixirJobsWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /assets/css/_config.scss: -------------------------------------------------------------------------------- 1 | $info: darken(#bf88e8, 20%); 2 | $primary: #5c18bf; 3 | $primary-light: #c9b5f2; 4 | $navbar_color: #5c18bf; 5 | -------------------------------------------------------------------------------- /assets/static/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odarriba/elixir_jobs/HEAD/assets/static/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /test/elixir_jobs_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.PageViewTest do 2 | use ElixirJobsWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /assets/static/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odarriba/elixir_jobs/HEAD/assets/static/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/static/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odarriba/elixir_jobs/HEAD/assets/static/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /test/elixir_jobs_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.LayoutViewTest do 2 | use ElixirJobsWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Mailer do 2 | @moduledoc false 3 | 4 | use Bamboo.Mailer, otp_app: :elixir_jobs 5 | end 6 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/views/email_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.EmailView do 2 | use ElixirJobsWeb, :view 3 | 4 | alias ElixirJobsWeb.HumanizeHelper 5 | end 6 | -------------------------------------------------------------------------------- /assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "plugins": { 3 | // to edit target browsers: use "browserslist" field in package.json 4 | "autoprefixer": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /assets/css/components/_login.scss: -------------------------------------------------------------------------------- 1 | .login { 2 | width: 500px; 3 | max-width: 100%; 4 | 5 | margin: 2rem auto; 6 | padding: 1rem; 7 | 8 | border: 2px solid $light; 9 | border-radius: 10px; 10 | } 11 | -------------------------------------------------------------------------------- /assets/css/components/_section.scss: -------------------------------------------------------------------------------- 1 | @media screen and (max-width: $tablet) { 2 | .level-left + .level-right { 3 | margin-top: 1rem; 4 | } 5 | } 6 | 7 | .section { 8 | padding: 1.7rem 1.5rem; 9 | } 10 | -------------------------------------------------------------------------------- /assets/css/components/_layout.scss: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | min-height: 100vh; 4 | flex-direction: column; 5 | } 6 | 7 | .container { 8 | max-width: 960px !important; 9 | } 10 | 11 | .section.app { 12 | flex: 1; 13 | } 14 | -------------------------------------------------------------------------------- /assets/css/sections/offers-show.scss: -------------------------------------------------------------------------------- 1 | .offers-show { 2 | .offer { 3 | margin-top: 1rem; 4 | 5 | hr { 6 | margin: 0 0 1.5rem 0; 7 | } 8 | 9 | .body p { 10 | margin-bottom: 0.8rem; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/admin/offer/edit.html.eex: -------------------------------------------------------------------------------- 1 | <%= render(ElixirJobsWeb.OfferView, "_form.html", 2 | changeset: @changeset, 3 | action: admin_offer_path(@conn, :update, @offer.slug), 4 | conn: @conn, 5 | css_class: "offer-new") %> 6 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/layout/shared/_alerts.html.eex: -------------------------------------------------------------------------------- 1 | <%= for message <- get_flash_messages(@conn) do %> 2 |
3 | × 4 | <%= raw(message) %> 5 |
6 | <% end %> 7 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200208134001_change_offer_summary.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo.Migrations.ChangeOfferSummary do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table("offers") do 6 | modify(:summary, :text, null: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20171001195406_add_summary_to_offers.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo.Migrations.AddSummaryToOffers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:offers) do 6 | add :summary, :string, size: 350 7 | end 8 | 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/elixir_jobs_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.PageControllerTest do 2 | use ElixirJobsWeb.ConnCase 3 | 4 | # test "GET /", %{conn: conn} do 5 | # conn = get conn, "/" 6 | # assert html_response(conn, 200) =~ "Welcome to Phoenix!" 7 | # end 8 | end 9 | -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | User-agent: * 5 | Disallow: /login 6 | Disallow: /admin 7 | Sitemap: https://elixirjobs.net/sitemap.xml 8 | -------------------------------------------------------------------------------- /assets/static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #5c18bf 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /lib/elixir_jobs.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs do 2 | @moduledoc """ 3 | Elixir Jobs keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.PageController do 2 | use ElixirJobsWeb, :controller 3 | 4 | def about(conn, _params) do 5 | render(conn, "about.html") 6 | end 7 | 8 | def sponsors(conn, _params) do 9 | render(conn, "sponsors.html") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.ErrorView do 2 | use ElixirJobsWeb, :view 3 | 4 | # In case no render clause matches or no 5 | # template is found, let's render it as 500 6 | def template_not_found(_template, assigns) do 7 | render("500.html", assigns) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/test.secret.example.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :elixir_jobs, ElixirJobs.Repo, 5 | adapter: Ecto.Adapters.Postgres, 6 | username: "DB_USER", 7 | password: "DB_PASSWORD", 8 | database: "elixir_jobs_test", 9 | hostname: "localhost", 10 | pool: Ecto.Adapters.SQL.Sandbox 11 | -------------------------------------------------------------------------------- /assets/css/components/_about.scss: -------------------------------------------------------------------------------- 1 | .about { 2 | max-width: 800px; 3 | margin: 0 auto; 4 | 5 | .sponsors { 6 | margin-top: 50px; 7 | margin-bottom: 10px; 8 | 9 | .level-item { 10 | margin-bottom: 40px; 11 | } 12 | 13 | .learn-elixir { 14 | margin-bottom: 10px; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170913192410_add_slug_to_offers.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo.Migrations.AddSlugToOffers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:offers) do 6 | add :slug, :string, null: false 7 | end 8 | 9 | create index(:offers, [:slug], unique: true) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20171002122508_change_description_character_limit_offers_table.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo.Migrations.ChangeDescriptionCharacterLimitOffersTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:offers) do 6 | modify :description, :string, size: 1000 7 | end 8 | 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180504222508_change_limit_of_offers_summary.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo.Migrations.ChangeLimitOfOffersSummary do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:offers) do 6 | modify :description, :string, size: 1000, null: true 7 | modify :summary, :string, size: 450 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config/test.secret.ci.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :elixir_jobs, ElixirJobs.Repo, 5 | adapter: Ecto.Adapters.Postgres, 6 | username: System.fetch_env!("POSTGRES_USERNAME"), 7 | password: System.fetch_env!("POSTGRES_PASSWORD"), 8 | database: System.fetch_env!("POSTGRES_DB"), 9 | hostname: System.fetch_env!("POSTGRES_HOST"), 10 | pool: Ecto.Adapters.SQL.Sandbox 11 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/index/_no-offer.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | <%= gettext("Upss! There is nothing here") %> 6 |

7 | <%= gettext "Post a job offer!" %> 8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /assets/js/app/notifications.js: -------------------------------------------------------------------------------- 1 | function initNotifications() { 2 | var $closeNotifications = Array.prototype.slice.call(document.querySelectorAll('.notification .delete'), 0); 3 | if ($closeNotifications.length > 0) { 4 | $closeNotifications.forEach(function ($el) { 5 | $el.addEventListener('click', function () { 6 | $el.parentElement.remove() 7 | }); 8 | }); 9 | } 10 | } 11 | 12 | export { initNotifications } 13 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/layout/shared/_analytics.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170911144639_add_enums_to_offers.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo.Migrations.AddEnumsToOffers do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:offers) do 6 | add(:job_place, :string, null: false) 7 | add(:job_type, :string, null: false) 8 | end 9 | end 10 | 11 | def down do 12 | alter table(:offers) do 13 | remove(:job_place) 14 | remove(:job_type) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Elixir Jobs changelog 2 | 3 | ## v2.1 4 | 5 | - Updated to Elixir 1.9.1 and Erlang 22.0 6 | - Update Ecto and Phoenix 7 | - Update DateTime helper functions 8 | 9 | ## v2.0 10 | 11 | - Removed Markdown and long descriptions 12 | - Updated to Elixir 1.6.4 and Erlang 20.3.2 13 | - Add formatter 14 | - Add Credo 15 | - UI redesign 16 | 17 | ## v1.0 18 | 19 | - Initial version 20 | - Email alerts of new job postings 21 | - Send published jobs to Twitter 22 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190824101839_normalise_enum_fields.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo.Migrations.NormaliseEnumFields do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:offers) do 6 | modify(:job_place, :string, null: false) 7 | modify(:job_type, :string, null: false) 8 | end 9 | 10 | Ecto.Migration.execute("DROP TYPE IF EXISTS job_place") 11 | Ecto.Migration.execute("DROP TYPE IF EXISTS job_type") 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/elixir_jobs/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo do 2 | use Ecto.Repo, 3 | otp_app: :elixir_jobs, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | use Scrivener, page_size: Application.compile_env(:elixir_jobs, :items_per_page) 7 | 8 | @doc """ 9 | Dynamically loads the repository url from the 10 | DATABASE_URL environment variable. 11 | """ 12 | def init(_, opts) do 13 | {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170914194826_create_admins.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo.Migrations.CreateAdmins do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:admins, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :name, :string 8 | add :email, :string 9 | add :encrypted_password, :string 10 | 11 | timestamps() 12 | end 13 | 14 | create index(:admins, [:email], unique: true) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /assets/css/app.scss: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | @import "_config"; 3 | 4 | @import "~bulma/bulma.sass"; 5 | 6 | @import "components/layout"; 7 | @import "components/navbar"; 8 | @import "components/footer"; 9 | @import "components/hero"; 10 | @import "components/section"; 11 | @import "components/offer"; 12 | @import "components/about"; 13 | @import "components/login"; 14 | 15 | 16 | @import "sections/offers-index"; 17 | @import "sections/offers-show"; 18 | -------------------------------------------------------------------------------- /config/dev.secret.example.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :elixir_jobs, ElixirJobs.Repo, 5 | adapter: Ecto.Adapters.Postgres, 6 | username: "DB_USERNAME", 7 | password: "DB_PASSWORD", 8 | database: "elixir_jobs_dev", 9 | hostname: "localhost", 10 | pool_size: 10 11 | 12 | config :extwitter, :oauth, 13 | consumer_key: "", 14 | consumer_secret: "", 15 | access_token: "", 16 | access_token_secret: "" 17 | 18 | config :nadia, token: "" 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190824082508_change_timestamps.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo.Migrations.ChangeTimestamps do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:offers) do 6 | modify(:published_at, :utc_datetime) 7 | modify(:inserted_at, :utc_datetime) 8 | modify(:updated_at, :utc_datetime) 9 | end 10 | 11 | alter table(:admins) do 12 | modify(:inserted_at, :utc_datetime) 13 | modify(:updated_at, :utc_datetime) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.LayoutView do 2 | use ElixirJobsWeb, :view 3 | 4 | import Phoenix.Controller, only: [current_url: 1] 5 | 6 | alias ElixirJobsWeb.MicrodataHelper 7 | alias ElixirJobsWeb.SeoHelper 8 | alias ElixirJobsWeb.Telegram 9 | 10 | def get_flash_messages(%Plug.Conn{} = conn) do 11 | conn 12 | |> Phoenix.Controller.get_flash() 13 | |> Map.values() 14 | end 15 | 16 | def get_telegram_channel, do: Telegram.get_channel() 17 | end 18 | -------------------------------------------------------------------------------- /lib/elixir_jobs/accounts/queries/admin.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Accounts.Queries.Admin do 2 | @moduledoc """ 3 | Module to build queries related to the Admin schema 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | 8 | def by_id(query, id) do 9 | from a in query, where: a.id == ^id 10 | end 11 | 12 | def by_email(query, email) do 13 | from a in query, where: a.email == ^email 14 | end 15 | 16 | def only_admin_emails(query) do 17 | from admin in query, select: {admin.name, admin.email} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /assets/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Elixir Jobs", 3 | "short_name": "Elixir Jobs", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /test/support/factories/accounts/admin.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Factories.Accounts.Admin do 2 | @moduledoc false 3 | 4 | use ElixirJobs.Factories.Base, :admin 5 | 6 | alias ElixirJobs.Accounts.Schemas.Admin 7 | 8 | def build_factory do 9 | %{ 10 | email: Faker.Internet.email(), 11 | name: Faker.Person.name(), 12 | password: "123456", 13 | password_confirmation: "123456" 14 | } 15 | end 16 | 17 | def get_schema, do: %Admin{} 18 | 19 | def get_changeset(attrs), do: Admin.changeset(%Admin{}, attrs) 20 | end 21 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/preview.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | <%= render("_offer_header.html", conn: @conn, offer: @offer) %> 6 |
7 | 8 |
<%= text_to_html(@offer.summary) %>
9 | 10 | 15 |
16 |
17 | -------------------------------------------------------------------------------- /assets/css/sections/offers-index.scss: -------------------------------------------------------------------------------- 1 | .offers-index { 2 | .offer { 3 | margin-bottom: 1rem; 4 | padding: 1.4rem 1.25rem; 5 | 6 | &:hover { 7 | box-shadow: 0 2px 3px rgba(10, 10, 10, 0.2), 0 0 0 1px rgba(10, 10, 10, 0.3);; 8 | } 9 | } 10 | 11 | .no-offer { 12 | margin: 3rem 0; 13 | border: 2px solid $light; 14 | border-radius: 10px; 15 | padding: 3rem; 16 | position: relative; 17 | 18 | .content { 19 | text-align: center; 20 | 21 | .button { 22 | margin-top: 1rem; 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # ElixirJobs.Repo.insert!(%ElixirJobs.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | 13 | ElixirJobs.Accounts.create_admin(%{ 14 | email: "dummy@user.com", 15 | password: "123456", 16 | password_confirmation: "123456", 17 | name: "Dummy Admin" 18 | }) 19 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/controllers/sitemap_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.SitemapController do 2 | use ElixirJobsWeb, :controller 3 | 4 | alias ElixirJobs.Core 5 | 6 | @items_per_page Application.compile_env!(:elixir_jobs, :items_per_page) 7 | 8 | def sitemap(conn, _params) do 9 | offers = Core.list_offers(published: true) 10 | 11 | total_pages = 12 | offers 13 | |> length() 14 | |> Kernel./(@items_per_page) 15 | |> Float.ceil() 16 | |> round() 17 | 18 | render(conn, "sitemap.xml", total_pages: total_pages, offers: offers) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/plugs/guardian_pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Plugs.GuardianPipeline do 2 | @moduledoc """ 3 | Main pipeline for Guardian set-up on each request. 4 | """ 5 | 6 | use Guardian.Plug.Pipeline, otp_app: :elixir_jobs 7 | 8 | alias ElixirJobsWeb.Plugs.CurrentUser 9 | 10 | plug Guardian.Plug.Pipeline, 11 | module: ElixirJobsWeb.Guardian, 12 | error_handler: ElixirJobsWeb.AuthController 13 | 14 | plug Guardian.Plug.VerifySession 15 | plug Guardian.Plug.VerifyHeader 16 | plug Guardian.Plug.LoadResource, allow_blank: true 17 | 18 | plug CurrentUser 19 | end 20 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200208134019_add_contact_email_to_offers.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo.Migrations.AddContactEmailToOffers do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table("offers") do 6 | add(:contact_email, :string) 7 | end 8 | 9 | flush() 10 | 11 | ElixirJobs.Repo.update_all(ElixirJobs.Core.Schemas.Offer, set: [contact_email: "Unknown"]) 12 | 13 | alter table("offers") do 14 | modify(:contact_email, :string, null: false) 15 | end 16 | end 17 | 18 | def down do 19 | alter table("offers") do 20 | remove(:contact_email) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170911140505_create_offers.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Repo.Migrations.CreateOffers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:offers, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :title, :string, null: false, size: 50 8 | add :company, :string, null: false, size: 30 9 | add :description, :string, null: false, size: 500 10 | add :location, :string, null: false, size: 50 11 | add :url, :string, null: false, size: 255 12 | 13 | add :published_at, :naive_datetime, default: nil 14 | 15 | timestamps() 16 | end 17 | 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /assets/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Layer 1 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/layout/shared/_hero.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | <%= gettext("Connect with jobs that matter") %> 6 |

7 |

8 | <%= gettext("With Elixir Jobs you can find your next Elixir job directly on your feed.") %> 9 |

10 | <%= link(gettext("Post a job offer!"), to: offer_path(@conn, :new), class: "button new-offer-btn is-primary is-large") %> 11 |
12 |
13 |
14 |
15 | -------------------------------------------------------------------------------- /assets/js/app/navbar.js: -------------------------------------------------------------------------------- 1 | function initNavbar() { 2 | var $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0); 3 | if ($navbarBurgers.length > 0) { 4 | $navbarBurgers.forEach(function ($el) { 5 | $el.addEventListener('click', function () { 6 | var target = $el.dataset.target; 7 | var $target = document.getElementById(target); 8 | $el.classList.toggle('is-active'); 9 | $target.classList.toggle('mobile-opened'); 10 | 11 | if ($target.classList.contains('is-active')) { 12 | document.querySelectorAll('.navbar.main') 13 | } 14 | }); 15 | }); 16 | } 17 | } 18 | 19 | export { initNavbar } 20 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/plugs/current_user.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Plugs.CurrentUser do 2 | @moduledoc """ 3 | Plug to store current user (if defined) on the connection. 4 | """ 5 | 6 | import Plug.Conn 7 | 8 | alias ElixirJobs.Accounts.Schemas.Admin 9 | alias ElixirJobsWeb.Guardian.Plug, as: GuardianPlug 10 | 11 | def init(_), do: [] 12 | 13 | def call(conn, _) do 14 | case GuardianPlug.current_resource(conn) do 15 | %Admin{} = user -> assign(conn, :current_user, user) 16 | _ -> conn 17 | end 18 | end 19 | 20 | def current_user(conn) do 21 | Map.get(conn.assigns, :current_user) 22 | end 23 | 24 | def user_logged_in?(conn), do: !is_nil(Map.get(conn.assigns, :current_user)) 25 | end 26 | -------------------------------------------------------------------------------- /lib/elixir_jobs/release.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | @app :elixir_jobs 7 | 8 | def migrate do 9 | load_app() 10 | 11 | for repo <- repos() do 12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 13 | end 14 | end 15 | 16 | def rollback(repo, version) do 17 | load_app() 18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/index.html.eex: -------------------------------------------------------------------------------- 1 |
2 | <%= render("index/_filters.html", conn: @conn, aspect: "small", page_number: @page_number, total_pages: @total_pages) %> 3 | 4 | <%= for offer <- @offers do %> 5 | <%= link(to: offer_path(@conn, :show, offer.slug)) do %> 6 |
7 | <%= render("_offer_header.html", conn: @conn, offer: offer, details: false) %> 8 |
9 | <% end %> 10 | 11 | <% end %> 12 | 13 | <%= if length(@offers) == 0 do %> 14 | <%= render("index/_no-offer.html", conn: @conn) %> 15 | <% end %> 16 | 17 | <%= render("index/_pagination.html", conn: @conn, page_number: @page_number, total_pages: @total_pages) %> 18 |
19 | -------------------------------------------------------------------------------- /assets/css/components/_footer.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | margin-top: 1rem; 3 | padding: 3rem 1.5rem 2.5rem 1.5rem; 4 | background-color: rgba(0, 0, 0, 0.87); 5 | color: darken($white, 35%); 6 | 7 | font-size: 0.8rem; 8 | 9 | strong { 10 | color: darken($white, 35%); 11 | } 12 | 13 | .title { 14 | margin-bottom: 0.5rem; 15 | } 16 | 17 | .columns { 18 | margin-bottom: 0; 19 | } 20 | 21 | a { 22 | color: $white; 23 | } 24 | 25 | p { 26 | text-transform: uppercase; 27 | margin: 0.3rem 0; 28 | } 29 | 30 | .copyright { 31 | font-size: 0.7rem; 32 | font-weight: bold; 33 | text-transform: uppercase; 34 | color: whitesmoke 35 | } 36 | 37 | .section-title { 38 | margin-bottom: 0.9rem; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/guardian.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Guardian do 2 | @moduledoc """ 3 | Main Guardian module definition, including how to store and recover users from 4 | and to the session. 5 | """ 6 | 7 | use Guardian, otp_app: :elixir_jobs 8 | 9 | alias ElixirJobs.Accounts 10 | alias ElixirJobs.Accounts.Schemas.Admin 11 | 12 | def subject_for_token(%Admin{} = resource, _claims) do 13 | {:ok, to_string(resource.id)} 14 | end 15 | 16 | def subject_for_token(_, _) do 17 | {:error, :unknown_resource} 18 | end 19 | 20 | def resource_from_claims(%{"sub" => admin_id}) do 21 | admin = Accounts.get_admin!(admin_id) 22 | {:ok, admin} 23 | rescue 24 | Ecto.NoResultsError -> {:error, :resource_not_found} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/show.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= render("_offer_header.html", conn: @conn, offer: @offer) %> 4 |
5 | 6 |
<%= text_to_html(@offer.summary) %>
7 | 8 | 13 | 14 | <%= render("show/_share.html", conn: @conn, offer: @offer) %> 15 |
16 | 17 | <%= if user_logged_in?(@conn) do %> 18 | <%= render("show/_administration.html", conn: @conn, offer: @offer) %> 19 | <% end %> 20 |
21 | -------------------------------------------------------------------------------- /lib/elixir_jobs/accounts/services/authenticate_admin.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Accounts.Services.AuthenticateAdmin do 2 | @moduledoc """ 3 | Service to make administration authentication 4 | """ 5 | 6 | alias ElixirJobs.Accounts.Managers.Admin, as: AdminManager 7 | alias ElixirJobs.Accounts.Schemas.Admin 8 | 9 | @doc """ 10 | Receives email and password and tries to fetch the user from the database and 11 | authenticate it 12 | """ 13 | def call(email, password) do 14 | admin = AdminManager.get_admin_by_email!(email) 15 | 16 | case Admin.check_password(admin, password) do 17 | {:ok, admin} -> {:ok, admin} 18 | {:error, error} -> {:error, error} 19 | end 20 | rescue 21 | Ecto.NoResultsError -> Admin.dummy_check_password() 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/layout/email.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Job Offer For Review 8 | 9 | 10 |
11 | <%= @inner_content %> 12 |
13 |

- Elixir Jobs

14 |
15 |
16 | 17 | 24 | 25 | -------------------------------------------------------------------------------- /lib/elixir_jobs/accounts/accounts.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Accounts do 2 | @moduledoc """ 3 | The Accounts context 4 | """ 5 | 6 | alias ElixirJobs.Accounts.Managers 7 | alias ElixirJobs.Accounts.Services 8 | 9 | defdelegate list_admins(), to: Managers.Admin 10 | defdelegate get_admin!(id), to: Managers.Admin 11 | defdelegate get_admin_by_email!(email), to: Managers.Admin 12 | defdelegate create_admin(attrs \\ %{}), to: Managers.Admin 13 | defdelegate update_admin(admin, attrs), to: Managers.Admin 14 | defdelegate delete_admin(admin), to: Managers.Admin 15 | defdelegate change_admin(admin), to: Managers.Admin 16 | defdelegate admin_emails(), to: Managers.Admin 17 | 18 | defdelegate authenticate_admin(email, password), to: Services.AuthenticateAdmin, as: :call 19 | end 20 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/email/offer_created.text.eex: -------------------------------------------------------------------------------- 1 | <%= gettext("A new offer has been created!") %> 2 | ---- 3 | <%= gettext("Title") %>: <%= @offer.title %> 4 | <%= gettext("Company") %>: <%= @offer.company %> 5 | ---- 6 | <%= gettext("Summary") %>: <%= @offer.summary %> 7 | <%= gettext("Location") %>: <%= @offer.location %> 8 | <%= gettext("URL") %>: <%= @offer.url %> 9 | ---- 10 | <%= gettext("Place") %>: <%= HumanizeHelper.human_get_place(@offer.job_place, gettext("Unknown")) %> 11 | <%= gettext("Type") %>: <%= HumanizeHelper.human_get_type(@offer.job_type, gettext("Unknown")) %> 12 | ---- 13 | <%= gettext("Contact E-mail") %>: <%= @offer.contact_email %> 14 | ---- 15 | <%= gettext("View") %>: <%= ElixirJobsWeb.Router.Helpers.admin_offer_url(ElixirJobsWeb.Endpoint, :edit, @offer.slug) %> 16 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :elixir_jobs, ElixirJobsWeb.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | 12 | # BCrypt configuration 13 | config :bcrypt_elixir, :log_rounds, 4 14 | 15 | config :elixir_jobs, ElixirJobsWeb.Guardian, 16 | issuer: "Elixir Jobs", 17 | secret_key: "MY_T3ST_K3Y" 18 | 19 | config :elixir_jobs, ElixirJobsWeb.Mailer, adapter: Bamboo.TestAdapter 20 | 21 | config :bamboo, :refute_timeout, 3 22 | 23 | config :elixir_jobs, :default_app_email, "no-reply@elixirjobs.net" 24 | config :elixir_jobs, :analytics_id, "" 25 | 26 | # Import custom configuration 27 | import_config "test.secret.exs" 28 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/layout/shared/_cookies.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import ElixirJobsWeb.Gettext 9 | 10 | # Simple translation 11 | gettext "Here is the string to translate" 12 | 13 | # Plural translation 14 | ngettext "Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3 17 | 18 | # Domain-based translation 19 | dgettext "errors", "Here is the error message to translate" 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :elixir_jobs 24 | end 25 | -------------------------------------------------------------------------------- /test/support/factories/core/offer.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Factories.Core.Offer do 2 | @moduledoc false 3 | 4 | use ElixirJobs.Factories.Base, :offer 5 | 6 | alias ElixirJobs.Core.Fields.JobPlace 7 | alias ElixirJobs.Core.Fields.JobType 8 | alias ElixirJobs.Core.Schemas.Offer 9 | 10 | def build_factory do 11 | %{ 12 | title: Faker.Lorem.sentence(2), 13 | company: Faker.Lorem.sentence(2), 14 | location: Faker.StarWars.planet(), 15 | url: Faker.Internet.url(), 16 | contact_email: Faker.Internet.email(), 17 | summary: Faker.Lorem.sentence(8..10), 18 | job_place: Enum.random(JobPlace.available_values()), 19 | job_type: Enum.random(JobType.available_values()) 20 | } 21 | end 22 | 23 | def get_schema, do: %Offer{} 24 | 25 | def get_changeset(attrs), do: Offer.changeset(%Offer{}, attrs) 26 | end 27 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/auth/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 | <%= form_for @conn, auth_path(@conn, :create), [as: :auth], fn f -> %> 3 |
4 | <%= email_input(f, :email, 5 | id: "auth_email", 6 | class: "input is-medium", 7 | placeholder: gettext("Your account's e-mail"), 8 | required: true) %> 9 | <%= error_tag(f, :email) %> 10 |
11 |
12 | <%= password_input(f, :password, 13 | id: "auth_password", 14 | class: "input is-medium", 15 | placeholder: gettext("Your account's password"), 16 | required: true) %> 17 | <%= error_tag(f, :password) %> 18 |
19 |
20 | <%= submit("Submit", class: "button is-primary is-medium") %> 21 |
22 | <% end %> 23 |
24 | -------------------------------------------------------------------------------- /lib/elixir_jobs/core/core.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Core do 2 | @moduledoc """ 3 | The core context 4 | """ 5 | 6 | alias ElixirJobs.Core.Fields 7 | alias ElixirJobs.Core.Managers 8 | 9 | defdelegate list_offers(opts \\ Keyword.new()), to: Managers.Offer 10 | defdelegate get_offer!(id, opts \\ Keyword.new()), to: Managers.Offer 11 | defdelegate get_offer_by_slug!(slug, opts \\ Keyword.new()), to: Managers.Offer 12 | defdelegate create_offer(attrs), to: Managers.Offer 13 | defdelegate update_offer(offer, attrs), to: Managers.Offer 14 | defdelegate publish_offer(offer), to: Managers.Offer 15 | defdelegate delete_offer(offer), to: Managers.Offer 16 | defdelegate change_offer(offer), to: Managers.Offer 17 | 18 | defdelegate get_job_places(), to: Fields.JobPlace, as: :available_values 19 | defdelegate get_job_types(), to: Fields.JobType, as: :available_values 20 | end 21 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Factory do 2 | @moduledoc """ 3 | Main factory file, which contains all the building functions imported from 4 | the different factories in build time. 5 | """ 6 | 7 | alias ElixirJobs.Repo 8 | 9 | # Factories included 10 | use ElixirJobs.Factories.Accounts.Admin 11 | use ElixirJobs.Factories.Core.Offer 12 | 13 | def params_for(schema, attrs \\ []) do 14 | extra_attrs = Enum.into(attrs, %{}) 15 | 16 | schema 17 | |> build_factory() 18 | |> Map.merge(extra_attrs) 19 | end 20 | 21 | def build(schema, attrs \\ []) do 22 | schema 23 | |> params_for(attrs) 24 | |> get_changeset(schema) 25 | |> Ecto.Changeset.apply_changes() 26 | end 27 | 28 | def insert(schema, attrs \\ []) do 29 | schema 30 | |> params_for(attrs) 31 | |> get_changeset(schema) 32 | |> Repo.insert!() 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/admin/offer/_pagination.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 24 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for elixir-jobs on 2022-08-31T21:35:20+02:00 2 | 3 | app = "elixir-jobs" 4 | kill_signal = "SIGTERM" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [deploy] 9 | release_command = "/app/bin/migrate" 10 | 11 | [env] 12 | PHX_HOST = "elixir-jobs.fly.dev" 13 | PORT = "8080" 14 | 15 | [experimental] 16 | allowed_public_ports = [] 17 | auto_rollback = true 18 | 19 | [[services]] 20 | http_checks = [] 21 | internal_port = 8080 22 | processes = ["app"] 23 | protocol = "tcp" 24 | script_checks = [] 25 | [services.concurrency] 26 | hard_limit = 25 27 | soft_limit = 20 28 | type = "connections" 29 | 30 | [[services.ports]] 31 | force_https = true 32 | handlers = ["http"] 33 | port = 80 34 | 35 | [[services.ports]] 36 | handlers = ["tls", "http"] 37 | port = 443 38 | 39 | [[services.tcp_checks]] 40 | grace_period = "1s" 41 | interval = "15s" 42 | restart_limit = 0 43 | timeout = "2s" 44 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/index/_pagination.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 24 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/new.html.eex: -------------------------------------------------------------------------------- 1 | 2 |
3 |

<%= gettext("Hire a great Elixir/Erlang developer") %>

4 |

<%= raw gettext("Elixir/Erlang community is in a very vibrant moment. Start looking for your next hiring in the right place.") %>

5 | 6 |

<%= gettext("How does this work?") %>

7 | 8 |

<%= raw gettext("Once you submit your offer, we will review it (just to avoid SPAM or dead links) and, if everything is correct, we will publish it as soon as possible!") %>

9 | 10 |

<%= raw gettext("Publishing an offer on Elixir Jobs is free and will be also posted to our Twitter account, so feel free of %{follow_link} and retweet your offer when published!", 11 | follow_link: "#{gettext("follow us")}") %>

12 |
13 |
14 | 15 | <%= render "_form.html", 16 | changeset: @changeset, 17 | action: offer_path(@conn, :create), 18 | conn: @conn, 19 | css_class: "offer-new" %> 20 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/helpers/view_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.ViewHelper do 2 | @moduledoc """ 3 | Module with helpers commonly used in other views. 4 | """ 5 | 6 | alias ElixirJobsWeb.DateHelper 7 | 8 | def class_with_error(form, field, base_class) do 9 | if error_on_field?(form, field) do 10 | "#{base_class} error" 11 | else 12 | base_class 13 | end 14 | end 15 | 16 | def error_on_field?(form, field) do 17 | form.errors 18 | |> Enum.map(fn {attr, _message} -> attr end) 19 | |> Enum.member?(field) 20 | end 21 | 22 | def do_strip_tags(text) do 23 | text 24 | |> HtmlSanitizeEx.strip_tags() 25 | |> Phoenix.HTML.raw() 26 | end 27 | 28 | ### 29 | # XML related functions 30 | ### 31 | 32 | def xml_strip_tags(text) do 33 | {:safe, text} = do_strip_tags(text) 34 | text 35 | end 36 | 37 | @doc "Returns a date formatted for RSS clients." 38 | def xml_readable_date(date) do 39 | DateHelper.strftime(date, "%e %b %Y %T %z") 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/helpers/date_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.DateHelper do 2 | @moduledoc """ 3 | Module with date-related calculation and helper functions. 4 | """ 5 | 6 | def diff(date1, date2) do 7 | date1 = date1 |> castin() 8 | date2 = date2 |> castin() 9 | 10 | case Calendar.DateTime.diff(date1, date2) do 11 | {:ok, seconds, _, :before} -> -1 * seconds 12 | {:ok, seconds, _, _} -> seconds 13 | _ -> nil 14 | end 15 | end 16 | 17 | def strftime(date, format) do 18 | {:ok, string} = 19 | date 20 | |> castin() 21 | |> Calendar.Strftime.strftime(format) 22 | 23 | string 24 | end 25 | 26 | # Casts Ecto.DateTimes coming into this module 27 | defp castin(%DateTime{} = date) do 28 | date 29 | |> DateTime.to_naive() 30 | |> NaiveDateTime.to_erl() 31 | |> Calendar.DateTime.from_erl!("Etc/UTC") 32 | end 33 | 34 | defp castin(date) do 35 | date 36 | |> NaiveDateTime.to_erl() 37 | |> Calendar.DateTime.from_erl!("Etc/UTC") 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Oscar de Arriba Gonzalez 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /test/support/factories/base.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Factories.Base do 2 | @moduledoc """ 3 | Base module for project's factories. 4 | 5 | Includes macros used for building structs and persist them during tests 6 | """ 7 | defmacro __using__(factory) do 8 | quote do 9 | @behaviour ElixirJobs.Factories.Base.Behaviour 10 | @factory unquote(factory) 11 | @base_module unquote(__CALLER__.module) 12 | 13 | defmacro __using__(_) do 14 | quote do 15 | def build_factory(unquote(@factory)), 16 | do: unquote(@base_module).build_factory() 17 | 18 | def get_schema(unquote(@factory)), 19 | do: unquote(@base_module).get_schema() 20 | 21 | def get_changeset(attrs, unquote(@factory)), 22 | do: unquote(@base_module).get_changeset(attrs) 23 | end 24 | end 25 | end 26 | end 27 | 28 | defmodule Behaviour do 29 | @moduledoc false 30 | 31 | @callback build_factory() :: map() 32 | @callback get_schema() :: map() 33 | @callback get_changeset(map()) :: Ecto.Changeset.t() 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/elixir_jobs/accounts/services/authenticate_admin_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Accounts.Services.AuthenticateAdminTest do 2 | use ElixirJobs.DataCase 3 | 4 | alias ElixirJobs.Accounts.Schemas.Admin 5 | alias ElixirJobs.Accounts.Services.AuthenticateAdmin 6 | 7 | describe "AuthenticateAdmin.call/2" do 8 | test "authenticate admin users" do 9 | admin = insert(:admin) 10 | 11 | {result, resource} = AuthenticateAdmin.call(admin.email, admin.password) 12 | 13 | assert result == :ok 14 | assert %Admin{} = resource 15 | assert resource.id == admin.id 16 | end 17 | 18 | test "returns error on wrong password" do 19 | admin = insert(:admin) 20 | 21 | {result, resource} = AuthenticateAdmin.call(admin.email, "wadus") 22 | 23 | assert result == :error 24 | assert resource == :wrong_credentials 25 | end 26 | 27 | test "returns error on wrong email" do 28 | {result, resource} = AuthenticateAdmin.call("invent@email.com", "wadus") 29 | 30 | assert result == :error 31 | assert resource == :wrong_credentials 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/email/offer_created.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

<%= gettext("A new offer has been created!") %>

3 |
4 |
5 |

<%= gettext("Title") %>: <%= @offer.title %>

6 |

<%= gettext("Company") %>: <%= @offer.company %>

7 |
8 |

<%= gettext("Summary") %>: <%= @offer.summary %>

9 |

<%= gettext("Location") %>: <%= @offer.location %>

10 |

<%= gettext("URL") %>: <%= @offer.url %>

11 |
12 |

<%= gettext("Place") %>: <%= HumanizeHelper.human_get_place(@offer.job_place, gettext("Unknown")) %>

13 |

<%= gettext("Type") %>: <%= HumanizeHelper.human_get_type(@offer.job_type, gettext("Unknown")) %>

14 |
15 |

<%= gettext("Contact E-mail") %>: <%= @offer.contact_email %>

16 |
17 | <%= link(gettext("View Offer"), to: ElixirJobsWeb.Router.Helpers.admin_offer_url(ElixirJobsWeb.Endpoint, :edit, @offer.slug)) %> 18 |
19 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | inputs: [ 4 | "lib/**/*.{ex,exs}", 5 | "test/**/*.{ex,exs}", 6 | "mix.exs" 7 | ], 8 | locals_without_parens: [ 9 | ## ECTO 10 | # Query 11 | from: 2, 12 | 13 | # Schema 14 | field: 1, 15 | field: 2, 16 | field: 3, 17 | timestamps: 0, 18 | timestamps: 1, 19 | belongs_to: 2, 20 | belongs_to: 3, 21 | has_one: 2, 22 | has_one: 3, 23 | has_many: 2, 24 | has_many: 3, 25 | many_to_many: 2, 26 | many_to_many: 3, 27 | embeds_one: 2, 28 | embeds_one: 3, 29 | embeds_one: 4, 30 | embeds_many: 2, 31 | embeds_many: 3, 32 | embeds_many: 4, 33 | 34 | ## PLUG 35 | plug: 1, 36 | plug: 2, 37 | forward: 2, 38 | forward: 3, 39 | forward: 4, 40 | match: 2, 41 | match: 3, 42 | get: 2, 43 | get: 3, 44 | get: 4, 45 | post: 2, 46 | post: 3, 47 | put: 2, 48 | put: 3, 49 | patch: 2, 50 | patch: 3, 51 | delete: 2, 52 | delete: 3, 53 | options: 2, 54 | options: 3, 55 | pipe_through: 1, 56 | 57 | # EctoEnum 58 | defenum: 3 59 | ] 60 | ] 61 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/index/_filters.html.eex: -------------------------------------------------------------------------------- 1 | 29 |
30 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | alias Ecto.Adapters.SQL.Sandbox, as: SQLSandbox 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | use Phoenix.ChannelTest 24 | 25 | # The default endpoint for testing 26 | @endpoint ElixirJobsWeb.Endpoint 27 | 28 | import ElixirJobs.Factory 29 | end 30 | end 31 | 32 | setup tags do 33 | :ok = SQLSandbox.checkout(ElixirJobs.Repo) 34 | 35 | unless tags[:async] do 36 | SQLSandbox.mode(ElixirJobs.Repo, {:shared, self()}) 37 | end 38 | 39 | :ok 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", ElixirJobsWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | def connect(_params, socket) do 19 | {:ok, socket} 20 | end 21 | 22 | # Socket id's are topics that allow you to identify all sockets for a given user: 23 | # 24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 25 | # 26 | # Would allow you to broadcast a "disconnect" event and terminate 27 | # all active sockets and channels for a given user: 28 | # 29 | # ElixirJobsWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 30 | # 31 | # Returning `nil` makes this socket anonymous. 32 | def id(_socket), do: nil 33 | end 34 | -------------------------------------------------------------------------------- /test/elixir_jobs/core/fields/job_place.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Core.Fields.JobPlaceTest do 2 | use ElixirJobs.DataCase 3 | 4 | alias ElixirJobs.Core.Fields.JobPlace 5 | 6 | describe "JobPlace.cast/1" do 7 | test "recognises valid job places" do 8 | assert JobPlace.cast(:onsite) == {:ok, :onsite} 9 | assert JobPlace.cast("onsite") == {:ok, :onsite} 10 | end 11 | 12 | test "recognises invalid job places" do 13 | assert JobPlace.cast(:wadus) == :error 14 | assert JobPlace.cast(0) == :error 15 | end 16 | end 17 | 18 | describe "JobPlace.load/1" do 19 | test "translates valid job places" do 20 | assert JobPlace.load("onsite") == {:ok, :onsite} 21 | end 22 | 23 | test "does not translate invalid job places" do 24 | assert JobPlace.load("wadus") == :error 25 | assert JobPlace.load(0) == :error 26 | end 27 | end 28 | 29 | describe "JobPlace.dump/1" do 30 | test "translates valid job places" do 31 | assert JobPlace.dump(:onsite) == {:ok, "onsite"} 32 | end 33 | 34 | test "dump/1 does not translate invalid job places" do 35 | assert JobPlace.dump(:wadus) == :error 36 | assert JobPlace.dump(0) == :error 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/elixir_jobs/core/fields/job_type.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Core.Fields.JobTypeTest do 2 | use ElixirJobs.DataCase 3 | 4 | alias ElixirJobs.Core.Fields.JobType 5 | 6 | describe "JobType.cast/1" do 7 | test "recognises valid job types" do 8 | assert JobType.cast(:full_time) == {:ok, :full_time} 9 | assert JobType.cast("full_time") == {:ok, :full_time} 10 | end 11 | 12 | test "recognises invalid job types" do 13 | assert JobType.cast(:wadus) == :error 14 | assert JobType.cast(0) == :error 15 | end 16 | end 17 | 18 | describe "JobType.load/1" do 19 | test "translates valid job types" do 20 | assert JobType.load("full_time") == {:ok, :full_time} 21 | end 22 | 23 | test "does not translate invalid job types" do 24 | assert JobType.load("wadus") == :error 25 | assert JobType.load(0) == :error 26 | end 27 | end 28 | 29 | describe "JobType.dump/1" do 30 | test "translates valid job types" do 31 | assert JobType.dump(:full_time) == {:ok, "full_time"} 32 | end 33 | 34 | test "dump/1 does not translate invalid job types" do 35 | assert JobType.dump(:wadus) == :error 36 | assert JobType.dump(0) == :error 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /assets/css/components/_offer.scss: -------------------------------------------------------------------------------- 1 | .offer { 2 | padding-bottom: 10px; 3 | 4 | p.title { 5 | margin: 0 0 0.7em 0; 6 | color: $primary; 7 | font-size: 1.5rem; 8 | 9 | @media screen and (max-width: $tablet) { 10 | strong { 11 | display: block; 12 | } 13 | } 14 | 15 | small { 16 | font-weight: bold; 17 | text-transform: uppercase; 18 | font-size: 0.9rem; 19 | color: $grey; 20 | } 21 | } 22 | } 23 | 24 | .offer-actions.level { 25 | margin: 2em 0 2.5em 0; 26 | } 27 | 28 | .offer-share { 29 | text-align: center; 30 | 31 | .level { 32 | margin-top: 1em; 33 | } 34 | 35 | .share-buttons { 36 | text-align: center; 37 | margin-top: 10px; 38 | 39 | @media screen and (max-width: $tablet) { 40 | span.share-button { 41 | display: block; 42 | } 43 | 44 | a.button { 45 | width: 100%; 46 | max-width: 250px; 47 | } 48 | } 49 | } 50 | 51 | a.button.twitter { 52 | background-color: #26c4f1; 53 | color: #fff; 54 | } 55 | 56 | a.button.linkedin { 57 | background-color: #007bb6; 58 | color: #fff; 59 | } 60 | 61 | a.button.google { 62 | background-color: #e93f2e; 63 | color: #fff; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/email.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Email do 2 | @moduledoc """ 3 | Module with functions related to email sending. 4 | """ 5 | 6 | use Bamboo.Phoenix, view: ElixirJobsWeb.EmailView 7 | 8 | import ElixirJobsWeb.Gettext 9 | 10 | def notification_offer_created_html(offer, from \\ :default) do 11 | case ElixirJobs.Accounts.admin_emails() do 12 | [] -> 13 | [] 14 | 15 | recipients -> 16 | for recipient <- recipients do 17 | from 18 | |> put_basic_layouts(recipient) 19 | |> subject(gettext("Elixir Jobs - A new job offer was received")) 20 | |> render("offer_created.text", offer: offer) 21 | |> render("offer_created.html", offer: offer) 22 | |> ElixirJobsWeb.Mailer.deliver_later() 23 | end 24 | end 25 | end 26 | 27 | defp put_basic_layouts(from, recipient) do 28 | actual_from = get_from(from) 29 | 30 | new_email() 31 | |> to(recipient) 32 | |> from(actual_from) 33 | |> put_text_layout({ElixirJobsWeb.LayoutView, "email.text"}) 34 | |> put_html_layout({ElixirJobsWeb.LayoutView, "email.html"}) 35 | end 36 | 37 | defp get_from(:default), do: Application.get_env(:elixir_jobs, :default_app_email) 38 | defp get_from(from), do: from 39 | end 40 | -------------------------------------------------------------------------------- /test/elixir_jobs_web/controllers/emails/emails_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.EmailsTest do 2 | use ElixirJobsWeb.ConnCase 3 | use Bamboo.Test, shared: true 4 | 5 | alias ElixirJobs.Core 6 | 7 | import Ecto.Query, only: [from: 2] 8 | 9 | describe "offers" do 10 | test "emails get sent to admins on offer creation", %{conn: conn} do 11 | insert(:admin) 12 | insert(:admin) 13 | 14 | post conn, offer_path(conn, :create), offer: params_for(:offer) 15 | 16 | query = 17 | from offer in Core.Schemas.Offer, 18 | order_by: [desc: offer.inserted_at], 19 | limit: 1 20 | 21 | offer = ElixirJobs.Repo.one(query) 22 | 23 | for {:ok, email} <- ElixirJobsWeb.Email.notification_offer_created_html(offer) do 24 | assert_delivered_email(email) 25 | end 26 | end 27 | 28 | test "doesn't raise error without admins on offer creation", %{conn: conn} do 29 | conn = post conn, offer_path(conn, :create), offer: params_for(:offer) 30 | 31 | assert redirected_to(conn) == offer_path(conn, :new) 32 | 33 | assert get_flash(conn, :info) == 34 | "Job offer successfully sent! We will review and publish it soon" 35 | 36 | assert_no_emails_delivered() 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, we often load configuration from external 4 | # sources, such as your system environment. For this reason, 5 | # you won't find the :http configuration below, but set inside 6 | # ElixirJobsWeb.Endpoint.init/2 when load_from_system_env is 7 | # true. Any dynamic configuration should be done there. 8 | # 9 | # Don't forget to configure the url host to something meaningful, 10 | # Phoenix uses this information when generating URLs. 11 | # 12 | # Finally, we also include the path to a cache manifest 13 | # containing the digested version of static files. This 14 | # manifest is generated by the mix phx.digest task 15 | # which you typically run after static files are built. 16 | config :elixir_jobs, ElixirJobsWeb.Endpoint, 17 | force_ssl: [rewrite_on: [:x_forwarded_proto]], 18 | cache_static_manifest: "priv/static/cache_manifest.json" 19 | 20 | # Do not print debug messages in production 21 | config :logger, level: :info 22 | 23 | # BCrypt configuration 24 | config :bcrypt_elixir, :log_rounds, 10 25 | 26 | config :elixir_jobs, :default_app_email, "no-reply@elixirjobs.net" 27 | config :elixir_jobs, :analytics_id, "UA-106824960-1" 28 | config :elixir_jobs, :telegram_channel, "elixir_jobs" 29 | 30 | # AppSignal 31 | config :appsignal, :config, active: true 32 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | import Config 7 | 8 | # General application configuration 9 | config :elixir_jobs, 10 | ecto_repos: [ElixirJobs.Repo] 11 | 12 | config :elixir_jobs, 13 | items_per_page: 10 14 | 15 | # Configures the endpoint 16 | config :elixir_jobs, ElixirJobsWeb.Endpoint, 17 | url: [host: "localhost"], 18 | secret_key_base: "IcrUuGYFkGys3tgfqEL5aGCHiAq4/bz4UcpvXFpLZde9Z3oWv//NdWfkXWA2BLNd", 19 | render_errors: [view: ElixirJobsWeb.ErrorView, accepts: ~w(html json)], 20 | pubsub_server: ElixirJobs.PubSub 21 | 22 | # Appsignal 23 | config :appsignal, :config, 24 | otp_app: :elixir_jobs, 25 | name: "ElixirJobs", 26 | env: Mix.env(), 27 | active: false 28 | 29 | # Use Jason for JSON parsing in Phoenix 30 | config :phoenix, :json_library, Jason 31 | 32 | # Configures Elixir's Logger 33 | config :logger, :console, 34 | format: "$time $metadata[$level] $message\n", 35 | metadata: [:request_id] 36 | 37 | # Import environment specific config. This must remain at the bottom 38 | # of this file so it overrides the configuration defined above. 39 | import_config "#{Mix.env()}.exs" 40 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/telegram.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Telegram do 2 | @moduledoc """ 3 | Module to send new elixir job offers to telegram channel. 4 | """ 5 | 6 | alias ElixirJobs.Core.Schemas.Offer 7 | alias ElixirJobsWeb.Gettext 8 | alias ElixirJobsWeb.HumanizeHelper 9 | alias ElixirJobsWeb.Router.Helpers, as: Routehelpers 10 | 11 | require ElixirJobsWeb.Gettext 12 | 13 | def send(%Plug.Conn{} = conn, %Offer{} = offer) do 14 | channel = get_channel() 15 | send(conn, offer, channel) 16 | end 17 | 18 | def send(_, _, ""), do: :ok 19 | 20 | def send(%Plug.Conn{} = conn, %Offer{} = offer, channel) do 21 | job_type = HumanizeHelper.human_get_type(offer.job_type, Gettext.gettext("Unknown")) 22 | job_place = HumanizeHelper.human_get_place(offer.job_place, Gettext.gettext("Unknown")) 23 | 24 | text = """ 25 | *#{offer.title}* 26 | #{offer.company} (#{offer.location}) 27 | #{job_type} - #{job_place} 28 | #{Routehelpers.offer_url(conn, :show, offer.slug)} 29 | """ 30 | 31 | case Nadia.send_message("@#{channel}", text, parse_mode: "Markdown") do 32 | {:ok, _result} -> 33 | :ok 34 | 35 | error -> 36 | error 37 | end 38 | end 39 | 40 | def get_channel do 41 | Application.get_env(:elixir_jobs, :telegram_channel, "") 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | alias Ecto.Adapters.SQL.Sandbox, as: SQLSandbox 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import ElixirJobsWeb.Router.Helpers 26 | 27 | # The default endpoint for testing 28 | @endpoint ElixirJobsWeb.Endpoint 29 | 30 | import ElixirJobs.Factory 31 | end 32 | end 33 | 34 | setup tags do 35 | :ok = SQLSandbox.checkout(ElixirJobs.Repo) 36 | 37 | unless tags[:async] do 38 | SQLSandbox.mode(ElixirJobs.Repo, {:shared, self()}) 39 | end 40 | 41 | {:ok, conn: Phoenix.ConnTest.build_conn()} 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/show/_share.html.eex: -------------------------------------------------------------------------------- 1 | <% 2 | url = 3 | @conn 4 | |> offer_url(:show, @offer.slug) 5 | |> URI.encode() 6 | 7 | twitter_link = "https://twitter.com/intent/tweet?via=jobs_elixir&hashtags=myelixirstatus,jobs&text=#{@offer.title}&url=#{url}" 8 | linkedin_link = "http://www.linkedin.com/shareArticle?mini=true&url=#{url}&title=#{@offer.title}" 9 | google_link = "https://plus.google.com/share?url=#{url}" 10 | %> 11 | 12 |
13 |
14 | <%= gettext("Do you like this offer for someone else? Share it!") %> 15 |
16 | 21 |   22 | 27 |   28 | 33 |
34 |
35 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/error/404.html.eex: -------------------------------------------------------------------------------- 1 | !DOCTYPE html> 2 | 3 | 4 | <%= render(ElixirJobsWeb.LayoutView, "shared/_head.html", conn: @conn) %> 5 | 6 | 7 | 8 | <%= render ElixirJobsWeb.LayoutView, "shared/_navbar.html", conn: @conn %> 9 | 10 | 11 | 12 | 13 |
14 |
15 |

<%= gettext("Whoops! Nothing to see here") %>

16 |

<%= gettext("We couldn't find waht you are looking for...") %>

17 |
18 |

19 | <%= gettext("You can check newest offers on our ") %> 20 | <%= link(gettext("home page"), to: offer_path(@conn, :index)) %> 21 |

22 |
23 |
24 | 25 | 26 | 27 | 28 | <%= render ElixirJobsWeb.LayoutView, "shared/_footer.html", conn: @conn %> 29 | 30 | 31 | <%= render ElixirJobsWeb.LayoutView, "shared/_analytics.html", conn: @conn %> 32 | <%= render ElixirJobsWeb.LayoutView, "shared/_cookies.html", conn: @conn %> 33 | 34 | 35 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elixir_jobs", 3 | "description": "Elixir language jobs board", 4 | "version": "0.1.0", 5 | "repository": {}, 6 | "license": "MIT", 7 | "scripts": { 8 | "deploy": "NODE_ENV=production webpack --mode production --env production", 9 | "analyze": "NODE_ENV=test webpack --progress --color", 10 | "watch": "webpack --watch --watch-options-stdin --progress --color" 11 | }, 12 | "dependencies": { 13 | "bulma": "^0.7.5", 14 | "jquery": "^3.6.0", 15 | "particles.js": "^2.0.0", 16 | "phoenix": "^1.6.0", 17 | "phoenix_html": "^3.3.1" 18 | }, 19 | "devDependencies": { 20 | "@babel/core": "^7.0", 21 | "@babel/preset-env": "^7.15.6", 22 | "ajv": "^6.9.1", 23 | "babel-loader": "^8.2", 24 | "copy-webpack-plugin": "^9.0.1", 25 | "css-loader": "^6.2.0", 26 | "css-minimizer-webpack-plugin": "^3.0.2", 27 | "file-loader": "^6.2.0", 28 | "mini-css-extract-plugin": "^2.3.0", 29 | "node-sass": "^9.0.0", 30 | "sass-loader": "^13.3.2", 31 | "sass-resources-loader": "^1.3.1", 32 | "url-loader": "^4.1.1", 33 | "webpack": "^5.0", 34 | "webpack-bundle-analyzer": "^4.7.0", 35 | "webpack-cli": "^4.8", 36 | "webpack-manifest-plugin": "^4.0.2" 37 | }, 38 | "browserslist": [ 39 | "last 2 versions", 40 | "ie >= 10", 41 | "safari >= 10" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= render("shared/_head.html", conn: @conn) %> 5 | 6 | 7 | 8 | <%= render "shared/_navbar.html", conn: @conn %> 9 | 10 | <%= if @conn.assigns[:show_hero] do %> 11 | <%= render "shared/_hero.html", conn: @conn %> 12 | <% else %> 13 | 14 | <% end %> 15 | 16 |
17 |

18 | <%= raw gettext("Subscribe to our Telegram channel to get last job offers on your phone!") %> 19 | <%= link raw(gettext("Join now! ")), to: "https://t.me/#{Telegram.get_channel()}", class: "button is-small is-info is-rounded", target: "_blank" %> 20 |

21 |
22 | 23 |
24 |
25 | <%= render "shared/_alerts.html", conn: @conn %> 26 | <%= @inner_content %> 27 |
28 |
29 | 30 | <%= render "shared/_footer.html", conn: @conn %> 31 | 32 | 33 | <%= render "shared/_analytics.html", conn: @conn %> 34 | <%= render "shared/_cookies.html", conn: @conn %> 35 | <%= raw MicrodataHelper.render_microdata(@conn) %> 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/elixir_jobs/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Application do 2 | @moduledoc """ 3 | Main Application module. 4 | 5 | It starts all the processes related to the application in order to boot it up 6 | and start serving requests. 7 | """ 8 | use Application 9 | 10 | # See https://hexdocs.pm/elixir/Application.html 11 | # for more information on OTP Applications 12 | def start(_type, _args) do 13 | # Define workers and child supervisors to be supervised 14 | children = [ 15 | # Start the Ecto repository 16 | {ElixirJobs.Repo, []}, 17 | # Start the PubSub system 18 | {Phoenix.PubSub, name: ElixirJobs.PubSub}, 19 | # Start the endpoint when the application starts 20 | {ElixirJobsWeb.Endpoint, []} 21 | # Start your own worker by calling: ElixirJobs.Worker.start_link(arg1, arg2, arg3) 22 | # worker(ElixirJobs.Worker, [arg1, arg2, arg3]), 23 | ] 24 | 25 | # See https://hexdocs.pm/elixir/Supervisor.html 26 | # for other strategies and supported options 27 | opts = [strategy: :one_for_one, name: ElixirJobs.Supervisor] 28 | Supervisor.start_link(children, opts) 29 | end 30 | 31 | # Tell Phoenix to update the endpoint configuration 32 | # whenever the application is updated. 33 | def config_change(changed, _new, removed) do 34 | ElixirJobsWeb.Endpoint.config_change(changed, removed) 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/rss.xml.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= gettext("Elixir Jobs") %> 5 | <%= gettext "Find your next Elixir job." %> 6 | <%= offer_url(@conn, :index) %> 7 | <%= for offer <- @offers do %> 8 | 9 | <![CDATA[<%= xml_strip_tags(offer.title) %>]]> 10 | 12 | 13 | <%= gettext "Job place:" %> <%= HumanizeHelper.human_get_place(offer.job_place, gettext("Unknown")) %> 14 |
15 | 16 | <%= gettext "Job type:" %> <%= HumanizeHelper.human_get_type(offer.job_type, gettext("Unknown")) %> 17 |
18 | 19 | <%= gettext "Job location:" %> <%= xml_strip_tags(offer.location) %> 20 | 21 | 22 | <%= offer.summary %> 23 | ]]>
24 | <%= xml_readable_date(offer.inserted_at) %> 25 | ]]> 26 | ]]> 27 |
28 | <% end %> 29 |
30 |
31 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/controllers/auth_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.AuthController do 2 | use ElixirJobsWeb, :controller 3 | 4 | alias ElixirJobs.Accounts 5 | alias ElixirJobsWeb.Guardian 6 | 7 | plug :scrub_params, "auth" when action in [:create] 8 | 9 | def new(conn, _params) do 10 | render(conn, "new.html") 11 | end 12 | 13 | def create(conn, %{"auth" => auth_params}) do 14 | with {:ok, email} <- Map.fetch(auth_params, "email"), 15 | {:ok, password} <- Map.fetch(auth_params, "password"), 16 | {:ok, admin} <- Accounts.authenticate_admin(email, password) do 17 | conn 18 | |> Guardian.Plug.sign_in(admin) 19 | |> put_flash(:info, gettext("Welcome %{user_name}!", user_name: admin.name)) 20 | |> redirect(to: offer_path(conn, :index)) 21 | else 22 | _ -> 23 | conn 24 | |> put_flash(:error, "Invalid credentials!") 25 | |> render("new.html") 26 | end 27 | end 28 | 29 | def delete(conn, _params) do 30 | conn 31 | |> Guardian.Plug.sign_out() 32 | |> put_flash(:info, gettext("Successfully logged out! See you!")) 33 | |> redirect(to: auth_path(conn, :new)) 34 | end 35 | 36 | def auth_error(conn, {_type, _reason}, _opts) do 37 | conn 38 | |> Guardian.Plug.sign_out() 39 | |> put_flash(:error, gettext("Authentication required")) 40 | |> redirect(to: auth_path(conn, :new)) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /assets/css/components/_hero.scss: -------------------------------------------------------------------------------- 1 | .hero.main { 2 | .hero-body { 3 | padding-bottom: 2.5rem; 4 | padding-top: 5rem; 5 | } 6 | 7 | .title { 8 | margin-bottom: 1.5rem; 9 | font-size: 1.5rem; 10 | } 11 | 12 | .subtitle { 13 | font-size: 1rem; 14 | } 15 | 16 | .new-offer-btn { 17 | background-color: $navbar_color; 18 | margin-top: 0.9rem; 19 | z-index: 20; 20 | border: 1px solid $primary-light; 21 | } 22 | 23 | @media screen and (min-width: $tablet) { 24 | .hero-body { 25 | padding-bottom: 3.5rem; 26 | padding-top: 6rem; 27 | } 28 | .title { 29 | margin-bottom: 1.5rem; 30 | font-size: 1.7rem; 31 | } 32 | .subtitle { 33 | font-size: 1.3rem; 34 | } 35 | } 36 | 37 | @media screen and (min-width: $desktop) { 38 | .hero-body { 39 | padding-bottom: 4rem; 40 | padding-top: 7.5rem; 41 | } 42 | .title { 43 | margin-bottom: 2rem; 44 | font-size: 2.5rem; 45 | } 46 | } 47 | } 48 | 49 | .box.cta { 50 | border-radius: 0; 51 | border-left: none; 52 | border-right: none; 53 | margin-bottom: 0; 54 | padding: 15px 20px; 55 | 56 | .button { 57 | margin: 0 5px; 58 | } 59 | } 60 | 61 | .particles-container { 62 | position: relative; 63 | } 64 | 65 | #particles { 66 | position: absolute; 67 | top: 4.4rem; 68 | left: 0; 69 | width: 100%; 70 | height: calc(100% - 4.4rem); 71 | } 72 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/_offer_header.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | <%= @offer.title %> 5 | 6 | <%= @offer.company %> - <%= @offer.location %> 7 |

8 |
9 |
10 | 11 | <%= gettext("Date") %> 12 | 13 | <%= if @offer.published_at do %> 14 | <%= HumanizeHelper.readable_date(@offer.published_at) %> 15 | <% else %> 16 | <%= HumanizeHelper.readable_date(@offer.inserted_at) %> 17 | <% end %> 18 | 19 | 20 |
21 |
22 | 23 | <%= gettext("Workplace") %> 24 | <%= HumanizeHelper.human_get_place(@offer.job_place, gettext("Unknown")) %> 25 | 26 |
27 |
28 | 29 | <%= gettext("Type") %> 30 | <%= HumanizeHelper.human_get_type(@offer.job_type, gettext("Unknown")) %> 31 | 32 |
33 |
34 |
35 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/helpers/error_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.ErrorHelper do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:p, translate_error(error), class: "help is-danger") 14 | end) 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # Because error messages were defined within Ecto, we must 22 | # call the Gettext module passing our Gettext backend. We 23 | # also use the "errors" domain as translations are placed 24 | # in the errors.po file. 25 | # Ecto will pass the :count keyword if the error message is 26 | # meant to be pluralized. 27 | # On your own code and templates, depending on whether you 28 | # need the message to be pluralized or not, this could be 29 | # written simply as: 30 | # 31 | # dngettext "errors", "1 file", "%{count} files", count 32 | # dgettext "errors", "is invalid" 33 | # 34 | if count = opts[:count] do 35 | Gettext.dngettext(ElixirJobsWeb.Gettext, "errors", msg, msg, count, opts) 36 | else 37 | Gettext.dgettext(ElixirJobsWeb.Gettext, "errors", msg, opts) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/sitemap/sitemap.xml.eex: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | <%= offer_url(@conn, :index) %> 9 | daily 10 | 1.00 11 | 12 | <%= for filter <- ["onsite", "remote", "full_time", "part_time", "freelance"] do %> 13 | 14 | <%= offer_url(@conn, :index_filtered, filter) %> 15 | daily 16 | 0.95 17 | 18 | <% end %> 19 | 20 | <%= offer_url(@conn, :new) %> 21 | 0.80 22 | 23 | 24 | <%= page_url(@conn, :about) %> 25 | 0.80 26 | 27 | <%= for page <- (1..@total_pages) do %> 28 | 29 | <%= offer_url(@conn, :index, page: page) %> 30 | daily 31 | 0.70 32 | 33 | <% end %> 34 | 35 | <%= for offer <- @offers do %> 36 | 37 | <%= offer_url(@conn, :show, offer.slug) %> 38 | daily 39 | 0.90 40 | 41 | <% end %> 42 | 43 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/error/500.html.eex: -------------------------------------------------------------------------------- 1 | !DOCTYPE html> 2 | 3 | 4 | <%= render(ElixirJobsWeb.LayoutView, "shared/_head.html", conn: @conn) %> 5 | 6 | 7 | 8 | <%= render ElixirJobsWeb.LayoutView, "shared/_navbar.html", conn: @conn %> 9 | 10 | 11 | 12 | 13 |
14 |
15 |

<%= gettext("Whoops... This is an error!") %>

16 |

<%= gettext("We just have an error on our side") %>

17 |
18 |

19 | <%= gettext("We will check and fix it as soon as possible.") %> 20 |

21 |

22 | <%= gettext("You can check newest offers on our ") %> 23 | <%= link(gettext("home page"), to: offer_path(@conn, :index)) %> 24 |

25 |
26 |
27 | 28 | 29 | 30 | 31 | <%= render ElixirJobsWeb.LayoutView, "shared/_footer.html", conn: @conn %> 32 | 33 | 34 | <%= render ElixirJobsWeb.LayoutView, "shared/_analytics.html", conn: @conn %> 35 | <%= render ElixirJobsWeb.LayoutView, "shared/_cookies.html", conn: @conn %> 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/views/offer_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.OfferView do 2 | use ElixirJobsWeb, :view 3 | 4 | alias ElixirJobs.Core 5 | alias ElixirJobs.Core.Schemas.Offer 6 | 7 | alias ElixirJobsWeb.HumanizeHelper 8 | 9 | @utm_params [ 10 | {"utm_source", "elixirjobs.net"}, 11 | {"utm_medium", "job_board"}, 12 | {"utm_campaign", "elixirjobs.net"} 13 | ] 14 | 15 | def get_job_place_options(default) do 16 | Enum.reduce(Core.get_job_places(), [], fn option, acc -> 17 | select_option = [ 18 | {HumanizeHelper.get_place_text(option, default), option} 19 | ] 20 | 21 | acc ++ select_option 22 | end) 23 | end 24 | 25 | def get_job_type_options(default) do 26 | Enum.reduce(Core.get_job_types(), [], fn option, acc -> 27 | select_option = [ 28 | {HumanizeHelper.get_type_text(option, default), option} 29 | ] 30 | 31 | acc ++ select_option 32 | end) 33 | end 34 | 35 | def offer_url(%Offer{url: url}) do 36 | parsed_url = URI.parse(url) 37 | 38 | query = parsed_url.query || "" 39 | 40 | query_params = 41 | query 42 | |> URI.query_decoder() 43 | |> Enum.reject(fn 44 | {"utm_source", _} -> true 45 | {"utm_medium", _} -> true 46 | {"utm_campaign", _} -> true 47 | _ -> false 48 | end) 49 | |> Kernel.++(@utm_params) 50 | |> URI.encode_query() 51 | 52 | URI.to_string(%{parsed_url | query: query_params}) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /assets/css/components/_navbar.scss: -------------------------------------------------------------------------------- 1 | .navbar { 2 | -webkit-box-shadow: none; 3 | box-shadow: none; 4 | color: #fff; 5 | 6 | &.is-primary { 7 | background-color: $navbar_color; 8 | } 9 | 10 | &.is-transparent { 11 | background-color: transparent; 12 | } 13 | 14 | @media screen and (max-width: $desktop) { 15 | &.mobile-opened { 16 | background-color: $navbar_color; 17 | 18 | .navbar-menu { 19 | display: block; 20 | padding: 5px 10px; 21 | } 22 | } 23 | } 24 | 25 | .navbar-menu { 26 | background-color: $navbar_color; 27 | 28 | @media screen and (min-width: $desktop) { 29 | background-color: transparent; 30 | } 31 | 32 | a { 33 | text-transform: uppercase; 34 | color: $white; 35 | &:hover { 36 | color: $navbar_color; 37 | } 38 | } 39 | } 40 | 41 | .navbar-burger { 42 | height: 4.4rem; 43 | } 44 | 45 | .navbar-brand { 46 | color: #fff; 47 | font-family: sans-serif; 48 | font-size: 1.5rem; 49 | line-height: 32px; 50 | margin: 0 10px; 51 | font-weight: bold; 52 | 53 | .logo { 54 | margin-right: 5px; 55 | margin-top: -2px; 56 | } 57 | 58 | .navbar-item:hover { 59 | padding-bottom: 0.5rem; 60 | border-bottom: none; 61 | } 62 | } 63 | 64 | .navbar-item { 65 | line-height: 2.3; 66 | 67 | &:hover { 68 | padding-bottom: calc(0.5rem - 2px); 69 | border-bottom: 2px solid whitesmoke; 70 | } 71 | } 72 | } 73 | 74 | .navbar-spacing { 75 | margin-top: 70px 76 | } 77 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | import "phoenix_html" 2 | 3 | // Import local files 4 | // 5 | // Local files can be imported directly using relative 6 | // paths "./socket" or full ones "web/static/js/socket". 7 | 8 | // import socket from "./socket" 9 | 10 | import "jquery"; 11 | import * as particles from "./app/particles.js"; 12 | import * as navbar from "./app/navbar.js"; 13 | import * as notifications from "./app/notifications.js"; 14 | 15 | function navbarScroll() { 16 | var navbar = document.getElementsByClassName("navbar is-fixed-top")[0]; 17 | var has_hero = document.getElementsByClassName("hero main").length > 0; 18 | 19 | if (!has_hero) { 20 | return true; 21 | } 22 | 23 | if (navbar && (document.body.scrollTop > 50 || document.documentElement.scrollTop > 50)) { 24 | navbar.classList.remove("is-transparent"); 25 | } else { 26 | navbar.classList.add("is-transparent"); 27 | } 28 | } 29 | 30 | document.addEventListener("DOMContentLoaded", function () { 31 | window.onscroll = navbarScroll; 32 | 33 | particles.initParticles(); 34 | navbar.initNavbar(); 35 | notifications.initNotifications(); 36 | navbarScroll(); 37 | 38 | $(".offer-new form button#preview").click(function (evt) { 39 | evt.preventDefault(); 40 | 41 | var form = $(this).closest("form"), 42 | form_data = $(form).serialize(), 43 | $preview_div = $(".offer-new .offer-preview"); 44 | 45 | $.post($(this).data("url"), form_data, function (res) { 46 | $preview_div.show(); 47 | $preview_div.html(res); 48 | 49 | $('html, body').animate({ 50 | scrollTop: $preview_div.offset().top 51 | }, 'slow'); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/twitter.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Twitter do 2 | @moduledoc """ 3 | Twitter-related functions to ease the publishing of new offers in that social 4 | network. 5 | """ 6 | 7 | alias ElixirJobs.Core.Schemas.Offer 8 | alias ElixirJobsWeb.Router.Helpers, as: RouterHelpers 9 | 10 | alias ElixirJobsWeb.HumanizeHelper 11 | 12 | @short_link_length 25 13 | @twitter_limit 140 14 | @tags [ 15 | "job", 16 | "myelixirstatus", 17 | "elixirlang" 18 | ] 19 | 20 | def publish(%Plug.Conn{} = conn, %Offer{} = offer) do 21 | text = get_text(offer) 22 | tags = get_tags() 23 | url = get_url(conn, offer) 24 | 25 | status_length = String.length(text) + String.length(tags) + 3 + @short_link_length 26 | 27 | status = 28 | case status_length do 29 | n when n <= @twitter_limit -> 30 | Enum.join([text, tags, url], " ") 31 | 32 | n -> 33 | exceed = n - @twitter_limit 34 | max_text_length = String.length(text) - exceed 35 | 36 | short_text = 37 | text 38 | |> String.slice(0, max_text_length - 3) 39 | |> Kernel.<>("...") 40 | 41 | Enum.join([short_text, tags, url], " ") 42 | end 43 | 44 | ExTwitter.update(status) 45 | end 46 | 47 | defp get_text(%Offer{company: company, title: title, job_place: job_place}) do 48 | "#{title} @ #{company} / #{HumanizeHelper.human_get_place(job_place, "Unknown Place")}" 49 | end 50 | 51 | defp get_tags do 52 | Enum.map_join(@tags, " ", &"##{&1}") 53 | end 54 | 55 | defp get_url(%Plug.Conn{} = conn, %Offer{slug: slug}) do 56 | RouterHelpers.offer_url(conn, :show, slug) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | alias Ecto.Adapters.SQL.Sandbox, as: SQLSandbox 18 | 19 | using do 20 | quote do 21 | alias ElixirJobs.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import ElixirJobs.DataCase 27 | import ElixirJobs.Factory 28 | end 29 | end 30 | 31 | setup tags do 32 | :ok = SQLSandbox.checkout(ElixirJobs.Repo) 33 | 34 | unless tags[:async] do 35 | SQLSandbox.mode(ElixirJobs.Repo, {:shared, self()}) 36 | end 37 | 38 | :ok 39 | end 40 | 41 | @doc """ 42 | A helper that transform changeset errors to a map of messages. 43 | 44 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 45 | assert "password is too short" in errors_on(changeset).password 46 | assert %{password: ["password is too short"]} = errors_on(changeset) 47 | 48 | """ 49 | def errors_on(changeset) do 50 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 51 | Enum.reduce(opts, message, fn {key, value}, acc -> 52 | String.replace(acc, "%{#{key}}", to_string(value)) 53 | end) 54 | end) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elixir Jobs 2 | 3 | > [!NOTE] 4 | > This repo will be in read-only mode starting September 1st, 2023
5 | > Thanks to everyone who contributed to ElixirJobs 💜 6 | 7 | Elixir Jobs is a job board based in Elixir + Phoenix. 8 | 9 | ## Technologies used 10 | 11 | - Erlang 12 | - Elixir 13 | - Phoenix 14 | - NodeJS 15 | - PostgreSQL 16 | 17 | Some of the versions are set on `.tool_versions` file, so you can use it with [asdf version manager](https://github.com/asdf-vm/asdf) 18 | 19 | ## Start the project 20 | 21 | The project should be installed and set up like any other elixir project. 22 | 23 | ``` 24 | $ cd elixir_jobs 25 | $ mix deps.get 26 | $ mix ecto.create 27 | $ mix ecto.migrate 28 | ``` 29 | 30 | You might encounter some errors about the secrets files. That's because you need to copy the template files under `./config` and personalise them with your local configuration. 31 | 32 | Also, assets now live on `./assets`, so NPM and brunch configurations are there. 33 | 34 | ### Seeds 35 | 36 | The project has the model of Administrators, which take care of approving the offers before showing them on the site. 37 | 38 | You can create a dummy administration user (credetials: dummy@user.com / 123456) using the seeds: 39 | 40 | ``` 41 | $ mix run priv/repo/seeds.exs 42 | ``` 43 | 44 | ## Contribute 45 | 46 | All contributions are welcome, and we really hope this repo will serve for beginners as well for more advanced developers. 47 | 48 | If you have any doubt, feel free to ask, but always respecting our [Code of Conduct](https://github.com/odarriba/elixir_jobs/blob/master/CODE_OF_CONDUCT.md). 49 | 50 | To contribute, create a fork of the repository, make your changes and create a PR. And remember, talking on PRs/issues is a must! 51 | -------------------------------------------------------------------------------- /lib/elixir_jobs/core/fields/job_place.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Core.Fields.JobPlace do 2 | @moduledoc """ 3 | Field definition module to save in the database the type of an account 4 | """ 5 | 6 | use Ecto.Type 7 | 8 | @values [ 9 | :unknown, 10 | :onsite, 11 | :remote, 12 | :both 13 | ] 14 | 15 | def available_values, do: @values 16 | 17 | @doc false 18 | def type, do: :string 19 | 20 | @doc """ 21 | Cast an job place from the value input to verify that it's a registered value. 22 | 23 | ## Examples 24 | 25 | iex> cast(:onsite) 26 | {:ok, :onsite} 27 | 28 | iex> cast("onsite") 29 | {:ok, :onsite} 30 | 31 | iex> cast(:wadus) 32 | :error 33 | 34 | """ 35 | @spec cast(atom()) :: {:ok, atom()} | :error 36 | def cast(value) when value in @values, do: {:ok, value} 37 | def cast(value) when is_binary(value), do: load(value) 38 | def cast(_value), do: :error 39 | 40 | @doc """ 41 | Load a job place value from the adapter to adapt it to the desired format in the app. 42 | 43 | ## Examples 44 | 45 | iex> load("onsite") 46 | {:ok, :onsite} 47 | 48 | iex> load("wadus") 49 | :error 50 | 51 | """ 52 | @spec load(String.t()) :: {:ok, atom()} | :error 53 | def load(value) when is_binary(value) do 54 | @values 55 | |> Enum.find(fn k -> to_string(k) == value end) 56 | |> case do 57 | k when not is_nil(k) -> 58 | {:ok, k} 59 | 60 | _ -> 61 | :error 62 | end 63 | end 64 | 65 | def load(_), do: :error 66 | 67 | @doc """ 68 | Translate the value in the app side to the database type. 69 | 70 | ## Examples 71 | 72 | iex> dump(:onsite) 73 | {:ok, "onsite"} 74 | 75 | iex> dump(:wadus) 76 | :error 77 | 78 | """ 79 | @spec dump(atom()) :: {:ok, String.t()} | :error 80 | def dump(value) when value in @values, do: {:ok, to_string(value)} 81 | def dump(_), do: :error 82 | end 83 | -------------------------------------------------------------------------------- /lib/elixir_jobs/core/fields/job_type.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Core.Fields.JobType do 2 | @moduledoc """ 3 | Field definition module to save in the database the type of an account 4 | """ 5 | 6 | use Ecto.Type 7 | 8 | @values [ 9 | :unknown, 10 | :full_time, 11 | :part_time, 12 | :freelance 13 | ] 14 | 15 | def available_values, do: @values 16 | 17 | @doc false 18 | def type, do: :string 19 | 20 | @doc """ 21 | Cast an job type from the value input to verify that it's a registered value. 22 | 23 | ## Examples 24 | 25 | iex> cast(:full_time) 26 | {:ok, :full_time} 27 | 28 | iex> cast("full_time") 29 | {:ok, :full_time} 30 | 31 | iex> cast(:wadus) 32 | :error 33 | 34 | """ 35 | @spec cast(atom()) :: {:ok, atom()} | :error 36 | def cast(value) when value in @values, do: {:ok, value} 37 | def cast(value) when is_binary(value), do: load(value) 38 | def cast(_value), do: :error 39 | 40 | @doc """ 41 | Load a job type value from the adapter to adapt it to the desired format in the app. 42 | 43 | ## Examples 44 | 45 | iex> load("full_time") 46 | {:ok, :full_time} 47 | 48 | iex> load("wadus") 49 | :error 50 | 51 | """ 52 | @spec load(String.t()) :: {:ok, atom()} | :error 53 | def load(value) when is_binary(value) do 54 | @values 55 | |> Enum.find(fn k -> to_string(k) == value end) 56 | |> case do 57 | k when not is_nil(k) -> 58 | {:ok, k} 59 | 60 | _ -> 61 | :error 62 | end 63 | end 64 | 65 | def load(_), do: :error 66 | 67 | @doc """ 68 | Translate the value in the app side to the database type. 69 | 70 | ## Examples 71 | 72 | iex> dump(:full_time) 73 | {:ok, "full_time"} 74 | 75 | iex> dump(:wadus) 76 | :error 77 | 78 | """ 79 | @spec dump(atom()) :: {:ok, String.t()} | :error 80 | def dump(value) when value in @values, do: {:ok, to_string(value)} 81 | def dump(_), do: :error 82 | end 83 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/admin/offer/index_published.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

<%= gettext("Published offers") %>

5 |
6 |
7 |
8 |
9 | <%= gettext("Showing page %{page} of %{total_pages}", page: @page_number, total_pages: @total_pages) %> 10 |
11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | <%= if length(@offers) == 0 do %> 22 | 23 | 24 | 25 | <% else %> 26 | <%= for offer <- @offers do %> 27 | 28 | 29 | 30 | 39 | 40 | <% end %> 41 | <% end %> 42 | 43 |
<%= gettext("Offer") %><%= gettext("Publish date") %><%= gettext("Actions") %>
<%= gettext("Opps! There is nothing here...") %>
<%= "#{offer.title} @ #{offer.company}" %><%= HumanizeHelper.readable_date(offer.published_at, false) %> 31 | <%= link(gettext("View"), to: offer_path(@conn, :show, offer.slug), class: "button is-success is-small") %> 32 | <%= link(gettext("Edit"), to: admin_offer_path(@conn, :edit, offer.slug), class: "button is-info is-small") %> 33 | <%= link(gettext("Delete"), 34 | to: admin_offer_path(@conn, :delete, offer.slug), 35 | class: "button is-danger is-small", 36 | method: :delete, 37 | data: [confirm: gettext("Do you really want to delete this offer?")]) %> 38 |
44 | 45 | <%= render("_pagination.html", conn: @conn, page_number: @page_number, total_pages: @total_pages, method: :index_published) %> 46 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/layout/shared/_footer.html.eex: -------------------------------------------------------------------------------- 1 | 45 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/layout/shared/_navbar.html.eex: -------------------------------------------------------------------------------- 1 | 38 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :elixir_jobs 3 | 4 | socket "/socket", ElixirJobsWeb.UserSocket, 5 | websocket: true, 6 | longpoll: [timeout: 45_000] 7 | 8 | # Serve at "/" the static files from "priv/static" directory. 9 | # 10 | # You should set gzip to true if you are running phoenix.digest 11 | # when deploying your static files in production. 12 | plug Plug.Static, 13 | at: "/", 14 | from: :elixir_jobs, 15 | gzip: false, 16 | only: ~w(css fonts images icons js favicon.ico robots.txt manifest.json browserconfig.xml) 17 | 18 | # Code reloading can be explicitly enabled under the 19 | # :code_reloader configuration of your endpoint. 20 | if code_reloading? do 21 | socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) 22 | 23 | plug Phoenix.LiveReloader 24 | plug Phoenix.CodeReloader 25 | end 26 | 27 | plug Plug.RequestId 28 | plug Plug.Logger 29 | 30 | plug Plug.Parsers, 31 | parsers: [:urlencoded, :multipart, :json], 32 | pass: ["*/*"], 33 | json_decoder: Jason 34 | 35 | plug Plug.MethodOverride 36 | plug Plug.Head 37 | 38 | # The session will be stored in the cookie and signed, 39 | # this means its contents can be read but not tampered with. 40 | # Set :encryption_salt if you would also like to encrypt it. 41 | plug Plug.Session, 42 | store: :cookie, 43 | key: "_elixir_jobs_key", 44 | signing_salt: "JReEffn+" 45 | 46 | plug ElixirJobsWeb.Router 47 | 48 | @doc """ 49 | Callback invoked for dynamically configuring the endpoint. 50 | 51 | It receives the endpoint configuration and checks if 52 | configuration should be loaded from the system environment. 53 | """ 54 | @impl true 55 | def init(_key, config) do 56 | if config[:load_from_system_env] do 57 | port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" 58 | {:ok, Keyword.put(config, :http, [:inet6, port: port])} 59 | else 60 | {:ok, config} 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/admin/offer/index_unpublished.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

<%= gettext("Pending offers") %>

5 |
6 |
7 |
8 |
9 | <%= gettext("Showing page %{page} of %{total_pages}", page: @page_number, total_pages: @total_pages) %> 10 |
11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | <%= if length(@offers) == 0 do %> 22 | 23 | 24 | 25 | <% else %> 26 | <%= for offer <- @offers do %> 27 | 28 | 29 | 30 | 40 | 41 | <% end %> 42 | <% end %> 43 | 44 |
<%= gettext("Offer") %><%= gettext("Reception date") %><%= gettext("Actions") %>
<%= gettext("Opps! There is nothing here...") %>
<%= "#{offer.title} @ #{offer.company}" %><%= HumanizeHelper.readable_date(offer.inserted_at, false) %> 31 | <%= link(gettext("View"), to: offer_path(@conn, :show, offer.slug), class: "button is-success is-small") %> 32 | <%= link(gettext("Publish"), to: admin_offer_path(@conn, :publish, offer.slug), class: "button is-primary is-small") %> 33 | <%= link(gettext("Edit"), to: admin_offer_path(@conn, :edit, offer.slug), class: "button is-info is-small") %> 34 | <%= link(gettext("Delete"), 35 | to: admin_offer_path(@conn, :delete, offer.slug), 36 | class: "button is-danger is-small", 37 | method: :delete, 38 | data: [confirm: gettext("Do you really want to delete this offer?")]) %> 39 |
45 | 46 | <%= render("_pagination.html", conn: @conn, page_number: @page_number, total_pages: @total_pages, method: :index_unpublished) %> 47 | -------------------------------------------------------------------------------- /lib/elixir_jobs/core/queries/offer.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Core.Queries.Offer do 2 | @moduledoc """ 3 | Module to build queries related to the Offer schema 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | 8 | def build(query, opts) do 9 | Enum.reduce(opts, query, fn 10 | {:published, true}, q -> 11 | q 12 | |> published() 13 | |> order_published() 14 | 15 | {:published, false}, q -> 16 | unpublished(q) 17 | 18 | {:job_place, value}, q -> 19 | by_job_place(q, value) 20 | 21 | {:job_type, value}, q -> 22 | by_job_type(q, value) 23 | 24 | {:search_text, text}, q -> 25 | by_text(q, text) 26 | 27 | _, q -> 28 | q 29 | end) 30 | end 31 | 32 | def by_id(query, id) do 33 | from o in query, where: o.id == ^id 34 | end 35 | 36 | def by_slug(query, slug) do 37 | from o in query, where: o.slug == ^slug 38 | end 39 | 40 | def by_job_type(query, values) when is_list(values) do 41 | from o in query, where: o.job_type in ^values 42 | end 43 | 44 | def by_job_type(query, value) do 45 | from o in query, where: o.job_type == ^value 46 | end 47 | 48 | def by_job_place(query, values) when is_list(values) do 49 | from o in query, where: o.job_place in ^values 50 | end 51 | 52 | def by_job_place(query, value) do 53 | from o in query, where: o.job_place == ^value 54 | end 55 | 56 | def by_text(query, text) when is_binary(text) do 57 | text 58 | |> String.split(" ") 59 | |> Enum.map(&"%#{&1}%") 60 | |> Enum.reduce(query, fn keyword, q -> 61 | from o in q, 62 | where: 63 | ilike(o.title, ^keyword) or ilike(o.company, ^keyword) or ilike(o.summary, ^keyword) or 64 | ilike(o.location, ^keyword) 65 | end) 66 | end 67 | 68 | def published(query) do 69 | from o in query, 70 | where: not is_nil(o.published_at) and o.published_at <= ^DateTime.utc_now() 71 | end 72 | 73 | def unpublished(query) do 74 | from o in query, where: is_nil(o.published_at) 75 | end 76 | 77 | def order_published(query) do 78 | from o in query, order_by: [desc: o.published_at] 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/helpers/humanize_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.HumanizeHelper do 2 | @moduledoc false 3 | use ElixirJobsWeb, :helper 4 | 5 | alias ElixirJobsWeb.DateHelper 6 | 7 | def human_get_place("onsite", default), do: get_place_text(:onsite, default) 8 | def human_get_place("remote", default), do: get_place_text(:remote, default) 9 | def human_get_place("both", default), do: get_place_text(:both, default) 10 | def human_get_place(option, default), do: get_place_text(option, default) 11 | 12 | def human_get_type("full_time", default), do: get_type_text(:full_time, default) 13 | def human_get_type("part_time", default), do: get_type_text(:part_time, default) 14 | def human_get_type("freelance", default), do: get_type_text(:freelance, default) 15 | def human_get_type(option, default), do: get_type_text(option, default) 16 | 17 | @doc "Returns a date formatted for humans." 18 | def readable_date(date, use_abbrevs? \\ true) do 19 | if use_abbrevs? && this_year?(date) do 20 | cond do 21 | today?(date) -> 22 | "Today" 23 | 24 | yesterday?(date) -> 25 | "Yesterday" 26 | 27 | true -> 28 | DateHelper.strftime(date, "%e %b") 29 | end 30 | else 31 | DateHelper.strftime(date, "%e %b %Y") 32 | end 33 | end 34 | 35 | def get_place_text(:onsite, _default), do: gettext("On site") 36 | def get_place_text(:remote, _default), do: gettext("Remote") 37 | def get_place_text(:both, _default), do: gettext("Onsite / Remote") 38 | def get_place_text(_, default), do: default 39 | 40 | def get_type_text(:full_time, _default), do: gettext("Full time") 41 | def get_type_text(:part_time, _default), do: gettext("Part time") 42 | def get_type_text(:freelance, _default), do: gettext("Freelance") 43 | def get_type_text(_, default), do: default 44 | 45 | ### 46 | # Private functions 47 | ### 48 | 49 | defp this_year?(date), do: date.year == DateTime.utc_now().year 50 | 51 | defp today?(date) do 52 | now = DateTime.utc_now() 53 | date.day == now.day && date.month == now.month && date.year == now.year 54 | end 55 | 56 | def yesterday?(date) do 57 | now = DateTime.utc_now() 58 | difference = DateTime.diff(now, date) 59 | difference < 2 * 24 * 60 * 60 && difference > 1 * 24 * 60 * 60 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with brunch.io to recompile .js and .css sources. 9 | config :elixir_jobs, ElixirJobsWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [npm: ["run", "watch", "--stdin", cd: Path.expand("../assets/", __DIR__)]] 15 | 16 | # ## SSL Support 17 | # 18 | # In order to use HTTPS in development, a self-signed 19 | # certificate can be generated by running the following 20 | # command from your terminal: 21 | # 22 | # openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem 23 | # 24 | # The `http:` config above can be replaced with: 25 | # 26 | # https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"], 27 | # 28 | # If desired, both `http:` and `https:` keys can be 29 | # configured to run both http and https servers on 30 | # different ports. 31 | 32 | # Watch static and templates for browser reloading. 33 | config :elixir_jobs, ElixirJobsWeb.Endpoint, 34 | live_reload: [ 35 | patterns: [ 36 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 37 | ~r{priv/gettext/.*(po)$}, 38 | ~r{lib/elixir_jobs_web/views/.*(ex)$}, 39 | ~r{lib/elixir_jobs_web/templates/.*(eex)$} 40 | ] 41 | ] 42 | 43 | # Do not include metadata nor timestamps in development logs 44 | config :logger, :console, format: "[$level] $message\n" 45 | 46 | # Set a higher stacktrace during development. Avoid configuring such 47 | # in production as building large stacktraces may be expensive. 48 | config :phoenix, :stacktrace_depth, 20 49 | 50 | # BCrypt configuration 51 | config :bcrypt_elixir, :log_rounds, 10 52 | 53 | config :elixir_jobs, ElixirJobsWeb.Guardian, 54 | issuer: "Elixir Jobs", 55 | secret_key: "MY_D3V_K3Y" 56 | 57 | config :elixir_jobs, ElixirJobsWeb.Mailer, adapter: Bamboo.LocalAdapter 58 | 59 | config :elixir_jobs, :default_app_email, "no-reply@elixirjobs.net" 60 | config :elixir_jobs, :analytics_id, "" 61 | config :elixir_jobs, :telegram_channel, "elixir_jobs_st" 62 | 63 | # Import custom configuration 64 | import_config "dev.secret.exs" 65 | -------------------------------------------------------------------------------- /assets/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "assets/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket 5 | // and connect at the socket path in "lib/web/endpoint.ex": 6 | import {Socket} from "phoenix" 7 | 8 | let socket = new Socket("/socket", {params: {token: window.userToken}}) 9 | 10 | // When you connect, you'll often need to authenticate the client. 11 | // For example, imagine you have an authentication plug, `MyAuth`, 12 | // which authenticates the session and assigns a `:current_user`. 13 | // If the current user exists you can assign the user's token in 14 | // the connection for use in the layout. 15 | // 16 | // In your "lib/web/router.ex": 17 | // 18 | // pipeline :browser do 19 | // ... 20 | // plug MyAuth 21 | // plug :put_user_token 22 | // end 23 | // 24 | // defp put_user_token(conn, _) do 25 | // if current_user = conn.assigns[:current_user] do 26 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 27 | // assign(conn, :user_token, token) 28 | // else 29 | // conn 30 | // end 31 | // end 32 | // 33 | // Now you need to pass this token to JavaScript. You can do so 34 | // inside a script tag in "lib/web/templates/layout/app.html.eex": 35 | // 36 | // 37 | // 38 | // You will need to verify the user token in the "connect/2" function 39 | // in "lib/web/channels/user_socket.ex": 40 | // 41 | // def connect(%{"token" => token}, socket) do 42 | // # max_age: 1209600 is equivalent to two weeks in seconds 43 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 44 | // {:ok, user_id} -> 45 | // {:ok, assign(socket, :user, user_id)} 46 | // {:error, reason} -> 47 | // :error 48 | // end 49 | // end 50 | // 51 | // Finally, pass the token on connect as below. Or remove it 52 | // from connect if you don't care about authentication. 53 | 54 | socket.connect() 55 | 56 | // Now that you are connected, you can join channels with a topic: 57 | let channel = socket.channel("topic:subtopic", {}) 58 | channel.join() 59 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 60 | .receive("error", resp => { console.log("Unable to join", resp) }) 61 | 62 | export default socket 63 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/page/about.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

<%= raw gettext("What is Elixir Jobs?") %>

3 | 4 |

<%= raw gettext("Elixir Jobs is a site built to help developers to find their next Elixir job, and to help companies to reach the vibrant Elixir community.") %>

5 | 6 |

<%= raw gettext("Also, our aim is to gain visibility for the Elixir job market, showing potential offers to developers all around the world, and using the tools they like to use.") %>

7 | 8 |

<%= raw gettext("Why should I post my job offers here?") %>

9 | 10 |

<%= raw gettext("By posting your offer on our site, it will be distributed also using Telegram, RSS and Twitter (why not start %{following_us}?), so you will potentially reach more developers than using other ways. And more important: you will be reaching developers that want a new job.", 11 | following_us: "#{gettext("following us")}") %>

12 | 13 |

<%= raw gettext("Also, because you can post your job listing for free.") %>

14 | 15 |

<%= raw gettext("Get in touch") %>

16 | 17 |

<%= raw gettext("If you need to contact us regarding a job offer, for commercial enquiries or just to say something, you can send us an email to hi@elixirjobs.net") %>

18 | 19 |

<%= raw gettext("Sponsors") %>

20 | 21 |

<%= raw gettext("ElixirJobs runs smoothly thanks to our beloved sponsors, who contribute to the site allowing us to continually develop and maintain it.") %>

22 | 23 | 35 | 36 |

<%= raw gettext("If your company wants to become a sponsor of ElixirJobs, please send us an email to hi@elixirjobs.net") %>

37 |
38 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-22.04 8 | 9 | container: 10 | image: hexpm/elixir:1.15.4-erlang-26.0.2-alpine-3.18.2 11 | 12 | services: 13 | postgres: 14 | image: postgres 15 | ports: 16 | - 5432:5432 17 | env: 18 | POSTGRES_USER: elixir_jobs 19 | POSTGRES_PASSWORD: elixir_jobs 20 | POSTGRES_DB: elixir_jobs_test 21 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 22 | 23 | steps: 24 | - uses: actions/checkout@v1 25 | - name: Install Dependencies 26 | env: 27 | MIX_ENV: test 28 | POSTGRES_HOST: postgres 29 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} 30 | POSTGRES_USERNAME: elixir_jobs 31 | POSTGRES_PASSWORD: elixir_jobs 32 | POSTGRES_DB: elixir_jobs_test 33 | run: | 34 | cp config/test.secret.ci.exs config/test.secret.exs 35 | mix local.rebar --force 36 | mix local.hex --force 37 | apk add --update-cache build-base 38 | mix deps.get 39 | - name: Compile 40 | env: 41 | MIX_ENV: test 42 | POSTGRES_HOST: postgres 43 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} 44 | POSTGRES_USERNAME: elixir_jobs 45 | POSTGRES_PASSWORD: elixir_jobs 46 | POSTGRES_DB: elixir_jobs_test 47 | run: mix compile --warnings-as-errors 48 | - name: Run formatter 49 | env: 50 | MIX_ENV: test 51 | POSTGRES_HOST: postgres 52 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} 53 | POSTGRES_USERNAME: elixir_jobs 54 | POSTGRES_PASSWORD: elixir_jobs 55 | POSTGRES_DB: elixir_jobs_test 56 | run: mix format --check-formatted 57 | - name: Run Credo 58 | env: 59 | MIX_ENV: test 60 | POSTGRES_HOST: postgres 61 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} 62 | POSTGRES_USERNAME: elixir_jobs 63 | POSTGRES_PASSWORD: elixir_jobs 64 | POSTGRES_DB: elixir_jobs_test 65 | run: mix credo 66 | - name: Run Tests 67 | env: 68 | MIX_ENV: test 69 | POSTGRES_HOST: postgres 70 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} 71 | POSTGRES_USERNAME: elixir_jobs 72 | POSTGRES_PASSWORD: elixir_jobs 73 | POSTGRES_DB: elixir_jobs_test 74 | run: mix test 75 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :elixir_jobs, 7 | version: "0.0.1", 8 | elixir: "~> 1.12", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix] ++ Mix.compilers(), 11 | build_embedded: Mix.env() == :prod, 12 | start_permanent: Mix.env() == :prod, 13 | aliases: aliases(), 14 | deps: deps() 15 | ] 16 | end 17 | 18 | # Configuration for the OTP application. 19 | # 20 | # Type `mix help compile.app` for more information. 21 | def application do 22 | [ 23 | mod: {ElixirJobs.Application, []}, 24 | extra_applications: [:logger] 25 | ] 26 | end 27 | 28 | # Specifies which paths to compile per environment. 29 | defp elixirc_paths(:test), do: ["lib", "test/support"] 30 | defp elixirc_paths(_), do: ["lib"] 31 | 32 | # Specifies your project dependencies. 33 | # 34 | # Type `mix help deps` for examples and options. 35 | defp deps do 36 | [ 37 | {:appsignal_phoenix, "~> 2.0"}, 38 | {:bamboo_phoenix, "~> 1.0"}, 39 | {:bamboo, "~> 2.0"}, 40 | {:bcrypt_elixir, "~> 3.0"}, 41 | {:calendar, "~> 1.0"}, 42 | {:comeonin, "~> 5.3"}, 43 | {:credo, "~> 1.5", only: [:dev, :test], runtime: false}, 44 | {:ecto_sql, "~> 3.7"}, 45 | {:extwitter, "~> 0.13.0"}, 46 | {:faker, "~> 0.16", only: :test}, 47 | {:gettext, "~> 0.20.0"}, 48 | {:guardian, "~> 2.2"}, 49 | {:html_sanitize_ex, "~> 1.4"}, 50 | {:jason, "~> 1.2"}, 51 | {:nadia, "~> 0.7"}, 52 | {:oauther, "~> 1.1"}, 53 | {:phoenix_ecto, "~> 4.4"}, 54 | {:phoenix_html, "~> 3.0"}, 55 | {:phoenix_live_reload, "~> 1.3", only: :dev}, 56 | {:phoenix_pubsub, "~> 2.0"}, 57 | {:phoenix, "~> 1.6.0"}, 58 | {:plug_cowboy, "~> 2.1"}, 59 | {:postgrex, ">= 0.0.0"}, 60 | {:scrivener_ecto, "~> 2.7"}, 61 | {:slugger, "~> 0.3.0"} 62 | ] 63 | end 64 | 65 | # Aliases are shortcuts or tasks specific to the current project. 66 | # For example, to create, migrate and run the seeds file at once: 67 | # 68 | # $ mix ecto.setup 69 | # 70 | # See the documentation for `Mix` for more info on aliases. 71 | defp aliases do 72 | [ 73 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 74 | "ecto.reset": ["ecto.drop", "ecto.setup"], 75 | test: ["ecto.drop --quiet", "ecto.create --quiet", "ecto.migrate", "test"] 76 | ] 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Router do 2 | use ElixirJobsWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_flash 8 | plug :protect_from_forgery 9 | plug :put_secure_browser_headers 10 | 11 | plug ElixirJobsWeb.Plugs.GuardianPipeline 12 | end 13 | 14 | pipeline :authentication_required do 15 | plug Guardian.Plug.EnsureAuthenticated 16 | end 17 | 18 | pipeline :api do 19 | plug :accepts, ["json"] 20 | end 21 | 22 | scope "/", ElixirJobsWeb do 23 | # Use the default browser stack 24 | pipe_through :browser 25 | 26 | get "/", OfferController, :index 27 | get "/about", PageController, :about 28 | get "/sponsors", PageController, :sponsors 29 | get "/rss", OfferController, :rss 30 | get "/sitemap.xml", SitemapController, :sitemap 31 | get "/page/:page", OfferController, :index, as: :offer_page 32 | get "/search", OfferController, :search 33 | get "/offers/filter/:filter", OfferController, :index_filtered 34 | get "/offers/place/:filter", OfferController, :index_filtered 35 | get "/offers/new", OfferController, :new 36 | post "/offers/new", OfferController, :create 37 | post "/offers/preview", OfferController, :preview 38 | put "/offers/preview", OfferController, :preview 39 | get "/offers/:slug", OfferController, :show 40 | 41 | get "/login", AuthController, :new 42 | post "/login", AuthController, :create 43 | end 44 | 45 | if Mix.env() == :dev do 46 | forward "/sent_emails", Bamboo.SentEmailViewerPlug 47 | end 48 | 49 | scope "/", ElixirJobsWeb do 50 | # Use the default browser stack 51 | pipe_through [:browser, :authentication_required] 52 | 53 | get "/logout", AuthController, :delete 54 | 55 | scope "/admin", Admin, as: :admin do 56 | get "/offers/published", OfferController, :index_published 57 | get "/offers/pending", OfferController, :index_unpublished 58 | get "/offers/:slug/publish", OfferController, :publish 59 | get "/offers/:slug/send_twitter", OfferController, :send_twitter 60 | get "/offers/:slug/send_telegram", OfferController, :send_telegram 61 | get "/offers/:slug/edit", OfferController, :edit 62 | put "/offers/:slug/edit", OfferController, :update 63 | delete "/offers/:slug", OfferController, :delete 64 | end 65 | end 66 | 67 | # Other scopes may use custom stacks. 68 | # scope "/api", ElixirJobsWeb do 69 | # pipe_through :api 70 | # end 71 | end 72 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/layout/shared/_head.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= gettext("Elixir Jobs") %> - <%= SeoHelper.page_title(@conn) %> 5 | 6 | 7 | 8 | - <%= SeoHelper.page_title(@conn) %>" /> 9 | 10 | " /> 11 | 12 | 13 | - <%= SeoHelper.page_title(@conn) %>" /> 14 | 15 | " /> 16 | "> 17 | "> 18 | 19 | 20 | "> 21 | "> 22 | "> 23 | " color="#5c18bf"> 24 | 25 | 26 | 27 | 28 | "> 29 | 30 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should be %{count} character(s)" 54 | msgid_plural "should be %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have %{count} item(s)" 59 | msgid_plural "should have %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at least %{count} character(s)" 64 | msgid_plural "should be at least %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at most %{count} character(s)" 74 | msgid_plural "should be at most %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should have at most %{count} item(s)" 79 | msgid_plural "should have at most %{count} item(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | ## From Ecto.Changeset.validate_number/3 84 | msgid "must be less than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than %{number}" 88 | msgstr "" 89 | 90 | msgid "must be less than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be greater than or equal to %{number}" 94 | msgstr "" 95 | 96 | msgid "must be equal to %{number}" 97 | msgstr "" 98 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here as no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /lib/elixir_jobs/accounts/schemas/admin.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Accounts.Schemas.Admin do 2 | @moduledoc """ 3 | Admin schema 4 | """ 5 | 6 | use Ecto.Schema 7 | 8 | import Ecto.Changeset 9 | 10 | @primary_key {:id, :binary_id, autogenerate: true} 11 | @foreign_key_type :binary_id 12 | schema "admins" do 13 | field :email, :string 14 | field :encrypted_password, :string 15 | field :name, :string 16 | 17 | field :password, :string, virtual: true 18 | field :password_confirmation, :string, virtual: true 19 | 20 | timestamps(type: :utc_datetime) 21 | end 22 | 23 | @doc false 24 | def changeset(admin, attrs) do 25 | admin 26 | |> cast(attrs, [:name, :email, :password, :password_confirmation]) 27 | |> validate_required([:name, :email]) 28 | |> validate_passwords() 29 | |> unique_constraint(:email) 30 | |> generate_passwords() 31 | end 32 | 33 | @doc """ 34 | Function to check the `password` of a given `admin`. 35 | """ 36 | def check_password(admin, password) do 37 | case Bcrypt.verify_pass(password, admin.encrypted_password) do 38 | true -> {:ok, admin} 39 | _ -> {:error, :wrong_credentials} 40 | end 41 | end 42 | 43 | @doc """ 44 | Function to simulate checking a password to avoid time-based user discovery 45 | """ 46 | def dummy_check_password do 47 | Bcrypt.no_user_verify() 48 | {:error, :wrong_credentials} 49 | end 50 | 51 | # Function to validate passwords only if they are changed or the admin is new. 52 | # 53 | defp validate_passwords(changeset) do 54 | current_password_hash = get_field(changeset, :encrypted_password) 55 | new_password = get_change(changeset, :password) 56 | 57 | case [current_password_hash, new_password] do 58 | [nil, _] -> 59 | changeset 60 | |> validate_required([:password, :password_confirmation]) 61 | |> validate_confirmation(:password) 62 | 63 | [_, pass] when pass not in ["", nil] -> 64 | changeset 65 | |> validate_confirmation(:password, required: true) 66 | 67 | _ -> 68 | changeset 69 | end 70 | end 71 | 72 | # Function to generate password hash when creating/changing the password of an 73 | # admin account 74 | # 75 | defp generate_passwords(%Ecto.Changeset{errors: []} = changeset) do 76 | case get_field(changeset, :password) do 77 | password when password not in ["", nil] -> 78 | hash = Bcrypt.hash_pwd_salt(password) 79 | put_change(changeset, :encrypted_password, hash) 80 | 81 | _ -> 82 | changeset 83 | end 84 | end 85 | 86 | defp generate_passwords(changeset), do: changeset 87 | end 88 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use ElixirJobsWeb, :controller 9 | use ElixirJobsWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: ElixirJobsWeb 23 | import Plug.Conn 24 | import ElixirJobsWeb.Router.Helpers 25 | import ElixirJobsWeb.Gettext 26 | 27 | def user_logged_in?(conn), do: !is_nil(Map.get(conn.assigns, :current_user)) 28 | end 29 | end 30 | 31 | def view do 32 | quote do 33 | use Phoenix.View, 34 | root: "lib/elixir_jobs_web/templates", 35 | pattern: "**/*", 36 | namespace: ElixirJobsWeb 37 | 38 | use Appsignal.Phoenix.View 39 | 40 | # Import convenience functions from controllers 41 | import Phoenix.Controller, only: [get_flash: 2, view_module: 1] 42 | 43 | # Use all HTML functionality (forms, tags, etc) 44 | use Phoenix.HTML 45 | 46 | import ElixirJobsWeb.Router.Helpers 47 | import ElixirJobsWeb.ErrorHelper 48 | import ElixirJobsWeb.ViewHelper 49 | import ElixirJobsWeb.Gettext 50 | 51 | alias ElixirJobsWeb.DateHelper 52 | alias ElixirJobsWeb.HumanizeHelper 53 | 54 | def user_logged_in?(conn), do: !is_nil(Map.get(conn.assigns, :current_user)) 55 | end 56 | end 57 | 58 | def helper do 59 | quote do 60 | # Import convenience functions from controllers 61 | import Phoenix.Controller, only: [get_flash: 2, view_module: 1] 62 | 63 | # Use all HTML functionality (forms, tags, etc) 64 | use Phoenix.HTML 65 | 66 | import ElixirJobsWeb.Router.Helpers 67 | import ElixirJobsWeb.Gettext 68 | end 69 | end 70 | 71 | def router do 72 | quote do 73 | use Phoenix.Router 74 | import Plug.Conn 75 | import Phoenix.Controller 76 | end 77 | end 78 | 79 | def channel do 80 | quote do 81 | use Phoenix.Channel 82 | import ElixirJobsWeb.Gettext 83 | end 84 | end 85 | 86 | @doc """ 87 | When used, dispatch to the appropriate controller/view/etc. 88 | """ 89 | defmacro __using__(which) when is_atom(which) do 90 | apply(__MODULE__, which, []) 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | const isProd = process.env.NODE_ENV === 'production'; 5 | const isTest = process.env.NODE_ENV === 'test'; 6 | 7 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 8 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); 9 | 10 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 11 | const { WebpackManifestPlugin } = require('webpack-manifest-plugin'); 12 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 13 | 14 | const source_path = __dirname; 15 | const output_path = path.join(__dirname, '..', 'priv', 'static'); 16 | 17 | const plugins = [ 18 | new MiniCssExtractPlugin({ 19 | filename: "css/[name].css" 20 | }), 21 | new CopyWebpackPlugin({ 22 | patterns: [ 23 | { from: path.join(source_path, 'static') } 24 | ] 25 | }), 26 | new webpack.ProvidePlugin({ 27 | $: 'jquery', 28 | jQuery: 'jquery' 29 | }) 30 | ]; 31 | 32 | if (isTest) { 33 | plugins.push( 34 | new BundleAnalyzerPlugin() 35 | ); 36 | } 37 | 38 | if (isProd) { 39 | plugins.push( 40 | new WebpackManifestPlugin({ 41 | fileName: 'cache_manifest.json', 42 | basePath: source_path, 43 | publicPath: output_path 44 | }) 45 | ); 46 | }; 47 | 48 | module.exports = { 49 | devtool: isProd ? false : 'eval-source-map', 50 | mode: isProd ? 'production' : 'development', 51 | performance: { 52 | hints: isTest ? 'warning' : false 53 | }, 54 | plugins, 55 | context: source_path, 56 | entry: { 57 | app: [ 58 | './css/app.scss', 59 | './js/app.js' 60 | ], 61 | }, 62 | 63 | output: { 64 | path: output_path, 65 | filename: 'js/[name].js', 66 | chunkFilename: 'js/[name].js', 67 | publicPath: '/' 68 | }, 69 | 70 | resolve: { 71 | modules: [ 72 | 'deps', 73 | 'node_modules' 74 | ], 75 | extensions: ['.js', '.scss'] 76 | }, 77 | 78 | module: { 79 | rules: [{ 80 | test: /\.js$/, 81 | exclude: /node_modules/, 82 | use: [ 83 | 'babel-loader', 84 | ] 85 | }, 86 | { 87 | test: /.s?css$/, 88 | use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"], 89 | }, 90 | { 91 | test: /\.(woff2?|eot|ttf|otf|svg)(\?.*)?$/, 92 | loader: 'url-loader', 93 | options: { 94 | limit: 1000, 95 | name: 'fonts/[name].[hash:7].[ext]' 96 | } 97 | }] 98 | }, 99 | 100 | optimization: { 101 | minimizer: [ 102 | `...`, 103 | new CssMinimizerPlugin(), 104 | ], 105 | }, 106 | }; 107 | -------------------------------------------------------------------------------- /lib/elixir_jobs/core/schemas/offer.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Core.Schemas.Offer do 2 | @moduledoc """ 3 | Offer schema. 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | 8 | alias ElixirJobs.Core.Fields.JobPlace 9 | alias ElixirJobs.Core.Fields.JobType 10 | 11 | @primary_key {:id, :binary_id, autogenerate: true} 12 | @foreign_key_type :binary_id 13 | 14 | schema "offers" do 15 | field :title, :string 16 | field :company, :string 17 | field :location, :string 18 | field :url, :string 19 | field :slug, :string 20 | field :summary, :string 21 | 22 | field :job_place, JobPlace 23 | field :job_type, JobType 24 | 25 | field :contact_email, :string 26 | 27 | field :published_at, :utc_datetime 28 | 29 | timestamps(type: :utc_datetime) 30 | end 31 | 32 | @required_attrs [ 33 | :title, 34 | :company, 35 | :contact_email, 36 | :location, 37 | :url, 38 | :job_place, 39 | :job_type, 40 | :summary 41 | ] 42 | @optional_attrs [:published_at, :slug] 43 | 44 | @email_regexp ~r/^[A-Za-z0-9._%+-+']+@[A-Za-z0-9.-]+\.[A-Za-z]+$/ 45 | @url_regexp ~r/^\b((https?:\/\/?)[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|\/)))$/ 46 | 47 | @doc false 48 | def changeset(offer, attrs) do 49 | offer 50 | |> cast(attrs, @required_attrs ++ @optional_attrs) 51 | |> validate_required(@required_attrs) 52 | |> validate_length(:title, min: 5, max: 50) 53 | |> validate_length(:company, min: 2, max: 30) 54 | |> validate_length(:summary, min: 10, max: 2000) 55 | |> validate_length(:location, min: 3, max: 50) 56 | |> validate_length(:url, min: 1, max: 255) 57 | |> validate_format(:url, @url_regexp) 58 | |> validate_length(:contact_email, min: 1, max: 255) 59 | |> validate_format(:contact_email, @email_regexp) 60 | |> validate_inclusion(:job_place, JobPlace.available_values()) 61 | |> validate_inclusion(:job_type, JobType.available_values()) 62 | |> unique_constraint(:slug) 63 | |> generate_slug() 64 | end 65 | 66 | defp generate_slug(changeset) do 67 | case get_field(changeset, :slug) do 68 | nil -> put_change(changeset, :slug, do_generate_slug(changeset)) 69 | _ -> changeset 70 | end 71 | end 72 | 73 | defp do_generate_slug(changeset) do 74 | uid = 75 | Ecto.UUID.generate() 76 | |> to_string() 77 | |> String.split("-") 78 | |> List.first() 79 | 80 | title = 81 | changeset 82 | |> get_field(:title) 83 | |> Kernel.||("") 84 | |> Slugger.slugify_downcase() 85 | 86 | company = 87 | changeset 88 | |> get_field(:company) 89 | |> Kernel.||("") 90 | |> Slugger.slugify_downcase() 91 | 92 | "#{company}-#{title}-#{uid}" 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /assets/js/app/particles.js: -------------------------------------------------------------------------------- 1 | import "particles.js" 2 | 3 | function initParticles() { 4 | var has_hero = document.getElementsByClassName("hero main").length > 0; 5 | 6 | if (!has_hero) { 7 | return false; 8 | } 9 | 10 | particlesJS("particles", { 11 | "particles": { 12 | "number": { 13 | "value": 80, 14 | "density": { 15 | "enable": true, 16 | "value_area": 800 17 | } 18 | }, 19 | "color": { "value": "#ffffff" }, 20 | "shape": { 21 | "type": "circle", 22 | "stroke": { 23 | "width": 0, 24 | "color": "#000000" 25 | }, 26 | "polygon": { "nb_sides": 5 }, 27 | "image": { 28 | "src": "img/github.svg", 29 | "width": 100, 30 | "height": 100 31 | } 32 | }, 33 | "opacity": { 34 | "value": 0.5, 35 | "random": false, 36 | "anim": { 37 | "enable": false, 38 | "speed": 1, 39 | "opacity_min": 0.1, 40 | "sync": false 41 | } 42 | }, 43 | "size": { 44 | "value": 3, 45 | "random": true, 46 | "anim": { 47 | "enable": false, 48 | "speed": 40, 49 | "size_min": 0.1, 50 | "sync": false 51 | } 52 | }, 53 | "line_linked": { 54 | "enable": true, 55 | "distance": 150, 56 | "color": "#ffffff", 57 | "opacity": 0.4, 58 | "width": 1 59 | }, 60 | "move": { 61 | "enable": true, 62 | "speed": 0.7, 63 | "direction": "none", 64 | "random": false, 65 | "straight": false, 66 | "out_mode": "out", 67 | "bounce": false, 68 | "attract": { 69 | "enable": false, 70 | "rotateX": 600, 71 | "rotateY": 1200 72 | } 73 | } 74 | }, 75 | "interactivity": { 76 | "detect_on": "canvas", 77 | "events": { 78 | "onhover": { "enable": false, "mode": "repulse" }, 79 | "onclick": { "enable": false, "mode": "push" }, 80 | "resize": true 81 | }, 82 | "modes": { 83 | "grab": { 84 | "distance": 400, 85 | "line_linked": { "opacity": 1 } 86 | }, 87 | "bubble": { 88 | "distance": 400, 89 | "size": 40, 90 | "duration": 2, 91 | "opacity": 8, 92 | "speed": 3 93 | }, 94 | "repulse": { "distance": 200, "duration": 0.4 }, 95 | "push": { "particles_nb": 4 }, 96 | "remove": { "particles_nb": 2 } 97 | } 98 | }, 99 | "retina_detect": true 100 | }); 101 | } 102 | 103 | export { initParticles } 104 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/show/_administration.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

3 | <%= gettext("Administration") %> 4 |

5 | 6 |
7 |
8 |
9 | 10 |
11 |
12 |
13 |
14 | <%= text_input(:offer, :contact_email, value: @offer.contact_email, class: "input is-static") %> 15 |
16 |
17 |
18 |
19 | 20 |
21 |
22 | 23 |
24 |
25 |
26 |
27 | <%= text_input(:offer, :contact_email, value: @offer.published_at, class: "input is-static") %> 28 |
29 |
30 |
31 |
32 | 33 |
34 |
35 | 36 |
37 |
38 |
39 |
40 | <%= text_input(:offer, :contact_email, value: @offer.inserted_at, class: "input is-static") %> 41 |
42 |
43 |
44 |
45 |
46 | 47 | 80 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | if config_env() == :prod do 11 | database_url = 12 | System.get_env("DATABASE_URL") || 13 | raise """ 14 | environment variable DATABASE_URL is missing. 15 | For example: ecto://USER:PASS@HOST/DATABASE 16 | """ 17 | 18 | maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] 19 | 20 | # Configure your database 21 | config :elixir_jobs, ElixirJobs.Repo, 22 | adapter: Ecto.Adapters.Postgres, 23 | url: database_url, 24 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 25 | socket_options: maybe_ipv6 26 | 27 | config :elixir_jobs, ElixirJobsWeb.Guardian, 28 | issuer: "ElixirJobs", 29 | secret_key: System.get_env("GUARDIAN_SECRET_KEY") 30 | 31 | config :extwitter, :oauth, 32 | consumer_key: System.get_env("TWITTER_ACCESS_CONSUMER_KEY"), 33 | consumer_secret: System.get_env("TWITTER_ACCESS_CONSUMER_SECRET"), 34 | access_token: System.get_env("TWITTER_ACCESS_TOKEN"), 35 | access_token_secret: System.get_env("TWITTER_ACCESS_TOKEN_SECRET") 36 | 37 | config :nadia, token: {:system, "TELEGRAM_TOKEN", ""} 38 | 39 | config :elixir_jobs, ElixirJobsWeb.Mailer, 40 | adapter: Bamboo.MailgunAdapter, 41 | api_key: System.get_env("MAILGUN_API_KEY"), 42 | domain: "elixirjobs.net", 43 | base_uri: "https://api.eu.mailgun.net/v3" 44 | 45 | # The secret key base is used to sign/encrypt cookies and other secrets. 46 | # A default value is used in config/dev.exs and config/test.exs but you 47 | # want to use a different value for prod and you most likely don't want 48 | # to check this value into version control, so we use an environment 49 | # variable instead. 50 | secret_key_base = 51 | System.get_env("SECRET_KEY_BASE") || 52 | raise """ 53 | environment variable SECRET_KEY_BASE is missing. 54 | You can generate one by calling: mix phx.gen.secret 55 | """ 56 | 57 | host = System.fetch_env!("PHX_HOST") 58 | port = String.to_integer(System.fetch_env!("PORT")) 59 | 60 | config :elixir_jobs, ElixirJobsWeb.Endpoint, 61 | url: [host: host, port: 443, scheme: "https"], 62 | server: true, 63 | http: [ 64 | # Enable IPv6 and bind on all interfaces. 65 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 66 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 67 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 68 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 69 | port: port 70 | ], 71 | secret_key_base: secret_key_base 72 | end 73 | -------------------------------------------------------------------------------- /assets/static/images/sponsors/learn-elixir-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 31 | 32 | -------------------------------------------------------------------------------- /lib/elixir_jobs/accounts/managers/admin.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Accounts.Managers.Admin do 2 | @moduledoc """ 3 | The Admin manager. 4 | """ 5 | 6 | alias ElixirJobs.Accounts.Queries.Admin, as: AdminQuery 7 | alias ElixirJobs.Accounts.Schemas.Admin 8 | alias ElixirJobs.Repo 9 | 10 | @doc """ 11 | Returns the list of admins. 12 | 13 | ## Examples 14 | 15 | iex> list_admins() 16 | [%Admin{}, ...] 17 | 18 | """ 19 | def list_admins do 20 | Repo.all(Admin) 21 | end 22 | 23 | @doc """ 24 | Gets a single admin by id. 25 | 26 | Raises `Ecto.NoResultsError` if the Admin does not exist. 27 | 28 | ## Examples 29 | 30 | iex> get_admin!(123) 31 | %Admin{} 32 | 33 | iex> get_admin!(456) 34 | ** (Ecto.NoResultsError) 35 | 36 | """ 37 | def get_admin!(id) do 38 | Admin 39 | |> AdminQuery.by_id(id) 40 | |> Repo.one!() 41 | end 42 | 43 | @doc """ 44 | Gets a single admin by email. 45 | 46 | Raises `Ecto.NoResultsError` if the Admin does not exist. 47 | 48 | ## Examples 49 | 50 | iex> get_admin_by_email!(admin@elixirjobs.net) 51 | %Admin{} 52 | 53 | iex> get_admin_by_email!(wadus@gmail.com) 54 | ** (Ecto.NoResultsError) 55 | 56 | """ 57 | def get_admin_by_email!(email) do 58 | Admin 59 | |> AdminQuery.by_email(email) 60 | |> Repo.one!() 61 | end 62 | 63 | @doc """ 64 | Creates a admin. 65 | 66 | ## Examples 67 | 68 | iex> create_admin(%{field: value}) 69 | {:ok, %Admin{}} 70 | 71 | iex> create_admin(%{field: bad_value}) 72 | {:error, %Ecto.Changeset{}} 73 | 74 | """ 75 | def create_admin(attrs \\ %{}) do 76 | %Admin{} 77 | |> Admin.changeset(attrs) 78 | |> Repo.insert() 79 | end 80 | 81 | @doc """ 82 | Updates a admin. 83 | 84 | ## Examples 85 | 86 | iex> update_admin(admin, %{field: new_value}) 87 | {:ok, %Admin{}} 88 | 89 | iex> update_admin(admin, %{field: bad_value}) 90 | {:error, %Ecto.Changeset{}} 91 | 92 | """ 93 | def update_admin(%Admin{} = admin, attrs) do 94 | admin 95 | |> Admin.changeset(attrs) 96 | |> Repo.update() 97 | end 98 | 99 | @doc """ 100 | Deletes a Admin. 101 | 102 | ## Examples 103 | 104 | iex> delete_admin(admin) 105 | {:ok, %Admin{}} 106 | 107 | iex> delete_admin(admin) 108 | {:error, %Ecto.Changeset{}} 109 | 110 | """ 111 | def delete_admin(%Admin{} = admin) do 112 | Repo.delete(admin) 113 | end 114 | 115 | @doc """ 116 | Returns an `%Ecto.Changeset{}` for tracking admin changes. 117 | 118 | ## Examples 119 | 120 | iex> change_admin(admin) 121 | %Ecto.Changeset{source: %Admin{}} 122 | 123 | """ 124 | def change_admin(%Admin{} = admin) do 125 | Admin.changeset(admin, %{}) 126 | end 127 | 128 | @doc """ 129 | Returns an array of tuples with {%Admin{name}, %Admin{email}} to be used on email sending 130 | 131 | ## Examples 132 | 133 | iex> admin_emails 134 | {"admin_name", "admin_email"} 135 | """ 136 | 137 | def admin_emails do 138 | Admin 139 | |> AdminQuery.only_admin_emails() 140 | |> Repo.all() 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of 2 | # Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # 8 | # This file is based on these images: 9 | # 10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image 12 | # - https://pkgs.org/ - resource for finding needed packages 13 | # - Ex: hexpm/elixir:1.13.3-erlang-24.2-debian-bullseye-20210902-slim 14 | # 15 | ARG ELIXIR_VERSION=1.15.4 16 | ARG OTP_VERSION=25.3.2.5 17 | ARG NODE_VERSION=18 18 | ARG DEBIAN_VERSION=bullseye-20230612-slim 19 | 20 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 21 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 22 | 23 | FROM ${BUILDER_IMAGE} as builder 24 | 25 | # Install NodeJS repository 26 | RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash 27 | 28 | # install build dependencies 29 | RUN apt-get update -y && apt-get install -y build-essential curl git nodejs npm ca-certificates \ 30 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 31 | 32 | # prepare build dir 33 | WORKDIR /app 34 | 35 | # install hex + rebar 36 | RUN mix local.hex --force && \ 37 | mix local.rebar --force 38 | 39 | # set build ENV 40 | ENV MIX_ENV="prod" 41 | 42 | # install mix dependencies 43 | COPY mix.exs mix.lock ./ 44 | RUN mix deps.get --only $MIX_ENV 45 | RUN mkdir config 46 | 47 | # copy compile-time config files before we compile dependencies 48 | # to ensure any relevant config change will trigger the dependencies 49 | # to be re-compiled. 50 | COPY config/config.exs config/${MIX_ENV}.exs config/ 51 | RUN mix deps.compile 52 | 53 | # Compile assets and digest them 54 | COPY assets assets 55 | RUN npm --prefix assets ci && \ 56 | npm --prefix assets run deploy && \ 57 | mix phx.digest 58 | 59 | # Copy app code 60 | COPY priv priv 61 | COPY lib lib 62 | 63 | # Compile the release 64 | RUN mix compile 65 | 66 | # Changes to config/runtime.exs don't require recompiling the code 67 | COPY config/runtime.exs config/ 68 | 69 | COPY rel rel 70 | RUN mix release 71 | 72 | # start a new build stage so that the final image will only contain 73 | # the compiled release and other runtime necessities 74 | FROM ${RUNNER_IMAGE} 75 | 76 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ 77 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 78 | 79 | # Set the locale 80 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 81 | 82 | ENV LANG en_US.UTF-8 83 | ENV LANGUAGE en_US:en 84 | ENV LC_ALL en_US.UTF-8 85 | 86 | WORKDIR "/app" 87 | RUN chown nobody /app 88 | 89 | # set runner ENV 90 | ENV MIX_ENV="prod" 91 | 92 | # Only copy the final release from the build stage 93 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/elixir_jobs ./ 94 | 95 | USER nobody 96 | 97 | CMD ["/app/bin/server"] 98 | # Appended by flyctl 99 | ENV ECTO_IPV6 true 100 | ENV ERL_AFLAGS "-proto_dist inet6_tcp" 101 | -------------------------------------------------------------------------------- /assets/static/images/sponsors/learn-elixir.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 11 | 12 | 34 | 35 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/helpers/seo_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.SeoHelper do 2 | @moduledoc """ 3 | Module with SEO-related functions like the ones to generate descriptions, 4 | titles, etc. 5 | """ 6 | 7 | use ElixirJobsWeb, :view 8 | 9 | import Phoenix.Controller, only: [view_module: 1, view_template: 1] 10 | 11 | @default_page_title "Find your next job the right way" 12 | @default_page_description "Elixir Jobs helps developers to find their next Elixir job and companies to spread their offers. Use our search engine to find your next dream job." 13 | 14 | alias ElixirJobs.Core.Schemas.Offer 15 | alias ElixirJobsWeb.ErrorView 16 | alias ElixirJobsWeb.OfferView 17 | alias ElixirJobsWeb.PageView 18 | 19 | def page_title(%Plug.Conn{} = conn) do 20 | get_page_title(view_module(conn), view_template(conn), conn.assigns, conn.params) 21 | end 22 | 23 | def page_title(_), do: gettext(@default_page_title) 24 | 25 | def page_description(%Plug.Conn{} = conn) do 26 | get_page_description(view_module(conn), view_template(conn), conn.assigns) 27 | end 28 | 29 | def page_description(_), do: gettext(@default_page_description) 30 | 31 | defp get_page_title(OfferView, "new.html", _, _), do: gettext("Publish a job offer") 32 | 33 | defp get_page_title(OfferView, action, _, params) 34 | when action in [:index_filtered, :search] do 35 | job_type = 36 | params 37 | |> Map.get("filters", %{}) 38 | |> Map.get("job_type", "") 39 | 40 | job_place = 41 | params 42 | |> Map.get("filters", %{}) 43 | |> Map.get("job_place", "") 44 | 45 | case {job_type, job_place} do 46 | {"full_time", ""} -> gettext("Full time Elixir job offers") 47 | {"part_time", ""} -> gettext("Part time Elixir job offers") 48 | {"freelance", ""} -> gettext("Freelance Elixir job offers") 49 | {"", "onsite"} -> gettext("On site Elixir job offers") 50 | {"", "remote"} -> gettext("Remote Elixir job offers") 51 | _ -> gettext(@default_page_title) 52 | end 53 | end 54 | 55 | defp get_page_title(OfferView, "show.html", %{:offer => %Offer{} = offer}, _), 56 | do: "#{offer.title} @ #{offer.company}" 57 | 58 | defp get_page_title(ErrorView, "404.html", _, _), 59 | do: gettext("Not Found") 60 | 61 | defp get_page_title(ErrorView, "500.html", _, _), 62 | do: gettext("Internal Error") 63 | 64 | defp get_page_title(PageView, "about.html", _, _), do: gettext("About") 65 | 66 | defp get_page_title(AuthView, action, _, _) when action in [:new, :create], 67 | do: gettext("Log in") 68 | 69 | defp get_page_title(_, _, _, _), do: gettext(@default_page_title) 70 | 71 | defp get_page_description(OfferView, "new.html", _), 72 | do: 73 | gettext( 74 | "Post your job offer to reach more Elixir developers and find the right hire for your company!" 75 | ) 76 | 77 | defp get_page_description(OfferView, "show.html", %{:offer => %Offer{} = offer}) do 78 | offer.summary 79 | |> HtmlSanitizeEx.strip_tags() 80 | |> String.slice(0, 100) 81 | end 82 | 83 | defp get_page_description(PageView, "about.html", _), 84 | do: 85 | gettext( 86 | "Built on Elixir + Phoenix, Elixir Jobs is a open source project that aims to help Elixir developers to find their next dream job." 87 | ) 88 | 89 | defp get_page_description(_, _, _), do: gettext(@default_page_description) 90 | end 91 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/helpers/microdata_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.MicrodataHelper do 2 | @moduledoc false 3 | use ElixirJobsWeb, :helper 4 | 5 | alias ElixirJobs.Core.Schemas.Offer 6 | alias ElixirJobsWeb.DateHelper 7 | 8 | use Phoenix.HTML 9 | 10 | def render_microdata(%Plug.Conn{} = conn) do 11 | case get_microdata(conn) do 12 | microdata when is_map(microdata) -> 13 | "" 14 | 15 | microdatas when is_list(microdatas) -> 16 | Enum.map_join(microdatas, fn data -> 17 | "" 18 | end) 19 | 20 | _ -> 21 | "" 22 | end 23 | end 24 | 25 | defp get_microdata(%Plug.Conn{} = conn) do 26 | organization_microdata = [organization_microdata(conn)] 27 | 28 | site_microdata = [ 29 | %{ 30 | "@context" => "http://schema.org", 31 | "@type" => "WebSite", 32 | "maintainer" => organization_microdata, 33 | "url" => ElixirJobsWeb.Endpoint.url() <> "/" 34 | } 35 | ] 36 | 37 | offer_microdata = 38 | case conn.assigns do 39 | %{offer: %Offer{} = offer} -> 40 | [offer_microdata(conn, offer)] 41 | 42 | _ -> 43 | [] 44 | end 45 | 46 | site_microdata ++ organization_microdata ++ offer_microdata 47 | end 48 | 49 | defp offer_microdata(conn, offer) do 50 | publication_date = offer.published_at || offer.inserted_at 51 | publication_date_str = DateHelper.strftime(publication_date, "%Y-%m-%d") 52 | 53 | employment_type = 54 | case offer.job_type do 55 | :full_time -> gettext("Full time") 56 | :part_time -> gettext("Part time") 57 | :freelance -> gettext("Freelance") 58 | _ -> gettext("Unknown") 59 | end 60 | 61 | job_description = 62 | offer.summary 63 | |> text_to_html() 64 | |> safe_to_string() 65 | 66 | base = %{ 67 | "@context" => "http://schema.org", 68 | "@type" => "JobPosting", 69 | "title" => offer.title, 70 | "description" => job_description, 71 | "datePosted" => publication_date_str, 72 | "employmentType" => employment_type, 73 | "url" => offer_url(conn, :show, offer.slug), 74 | "hiringOrganization" => %{ 75 | "@type" => "Organization", 76 | "@context" => "http://schema.org", 77 | "name" => offer.company 78 | }, 79 | "jobLocation" => %{ 80 | "@type" => "Place", 81 | "@context" => "http://schema.org", 82 | "address" => offer.location 83 | } 84 | } 85 | 86 | case offer.job_place do 87 | place when place in [:both, :remote] -> 88 | Map.put(base, "jobLocationType", "TELECOMMUTE") 89 | 90 | _ -> 91 | base 92 | end 93 | end 94 | 95 | defp organization_microdata(conn) do 96 | %{ 97 | "url" => ElixirJobsWeb.Endpoint.url() <> "/", 98 | "sameAs" => ["https://twitter.com/jobs_elixir"], 99 | "name" => gettext("ElixirJobs"), 100 | "image" => static_url(conn, "/images/logo.png"), 101 | "brand" => %{ 102 | "@type" => "Brand", 103 | "@context" => "http://schema.org", 104 | "name" => gettext("ElixirJobs"), 105 | "logo" => static_url(conn, "/images/logo.png"), 106 | "slogan" => gettext("Find your next job the right way") 107 | }, 108 | "@type" => "Organization", 109 | "@context" => "http://schema.org" 110 | } 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /config/staging.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, we often load configuration from external 4 | # sources, such as your system environment. For this reason, 5 | # you won't find the :http configuration below, but set inside 6 | # ElixirJobsWeb.Endpoint.init/2 when load_from_system_env is 7 | # true. Any dynamic configuration should be done there. 8 | # 9 | # Don't forget to configure the url host to something meaningful, 10 | # Phoenix uses this information when generating URLs. 11 | # 12 | # Finally, we also include the path to a cache manifest 13 | # containing the digested version of static files. This 14 | # manifest is generated by the mix phx.digest task 15 | # which you typically run after static files are built. 16 | config :elixir_jobs, ElixirJobsWeb.Endpoint, 17 | load_from_system_env: true, 18 | url: [scheme: "https", host: "staging.elixirjobs.net", port: 443], 19 | force_ssl: [rewrite_on: [:x_forwarded_proto]], 20 | cache_static_manifest: "priv/static/cache_manifest.json", 21 | secret_key_base: Map.fetch!(System.get_env(), "SECRET_KEY_BASE") 22 | 23 | # Do not print debug messages in production 24 | config :logger, level: :info 25 | 26 | # BCrypt configuration 27 | config :bcrypt_elixir, :log_rounds, 10 28 | 29 | # Configure your database 30 | config :elixir_jobs, ElixirJobs.Repo, 31 | adapter: Ecto.Adapters.Postgres, 32 | url: System.get_env("DATABASE_URL"), 33 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 34 | ssl: true 35 | 36 | config :elixir_jobs, ElixirJobsWeb.Guardian, 37 | issuer: "Elixir Jobs", 38 | secret_key: System.get_env("GUARDIAN_SECRET_KEY") 39 | 40 | config :extwitter, :oauth, 41 | consumer_key: System.get_env("TWITTER_ACCESS_CONSUMER_KEY"), 42 | consumer_secret: System.get_env("TWITTER_ACCESS_CONSUMER_SECRET"), 43 | access_token: System.get_env("TWITTER_ACCESS_TOKEN"), 44 | access_token_secret: System.get_env("TWITTER_ACCESS_TOKEN_SECRET") 45 | 46 | config :nadia, token: {:system, "TELEGRAM_TOKEN", ""} 47 | 48 | config :elixir_jobs, ElixirJobsWeb.Mailer, 49 | adapter: Bamboo.MailgunAdapter, 50 | api_key: System.get_env("MAILGUN_API_KEY"), 51 | domain: "elixirjobs.net" 52 | 53 | config :elixir_jobs, :default_app_email, "no-reply@staging.elixirjobs.net" 54 | config :elixir_jobs, :analytics_id, "" 55 | config :elixir_jobs, :telegram_channel, "elixir_jobs_st" 56 | 57 | # AppSignal 58 | config :appsignal, :config, active: true 59 | 60 | # ## SSL Support 61 | # 62 | # To get SSL working, you will need to add the `https` key 63 | # to the previous section and set your `:url` port to 443: 64 | # 65 | # config :elixir_jobs, ElixirJobsWeb.Endpoint, 66 | # ... 67 | # url: [host: "example.com", port: 443], 68 | # https: [:inet6, 69 | # port: 443, 70 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 71 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 72 | # 73 | # Where those two env variables return an absolute path to 74 | # the key and cert in disk or a relative path inside priv, 75 | # for example "priv/ssl/server.key". 76 | # 77 | # We also recommend setting `force_ssl`, ensuring no data is 78 | # ever sent via http, always redirecting to https: 79 | # 80 | # config :elixir_jobs, ElixirJobsWeb.Endpoint, 81 | # force_ssl: [hsts: true] 82 | # 83 | # Check `Plug.SSL` for all available options in `force_ssl`. 84 | 85 | # ## Using releases 86 | # 87 | # If you are doing OTP releases, you need to instruct Phoenix 88 | # to start the server for all endpoints: 89 | # 90 | # config :phoenix, :serve_endpoints, true 91 | # 92 | # Alternatively, you can configure exactly which server to 93 | # start per endpoint: 94 | # 95 | # config :elixir_jobs, ElixirJobsWeb.Endpoint, server: true 96 | # 97 | -------------------------------------------------------------------------------- /assets/static/images/sponsors/appsignal.svg: -------------------------------------------------------------------------------- 1 | Artboard 1 -------------------------------------------------------------------------------- /assets/static/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/osx,linux,elixir,erlang,phoenix,windows,sublimetext,visualstudiocode 2 | 3 | ### Elixir ### 4 | /_build 5 | /cover 6 | /deps 7 | /doc 8 | /.fetch 9 | erl_crash.dump 10 | *.ez 11 | *.beam 12 | 13 | ### Elixir Patch ### 14 | ### Erlang ### 15 | .eunit 16 | deps 17 | *.o 18 | *.plt 19 | ebin/*.beam 20 | rel/example_project 21 | .concrete/DEV_MODE 22 | .rebar 23 | 24 | ### Linux ### 25 | *~ 26 | 27 | # temporary files which can be created if a process still has a handle open of a deleted file 28 | .fuse_hidden* 29 | 30 | # KDE directory preferences 31 | .directory 32 | 33 | # Linux trash folder which might appear on any partition or disk 34 | .Trash-* 35 | 36 | # .nfs files are created when an open file is removed but is still being accessed 37 | .nfs* 38 | 39 | ### OSX ### 40 | *.DS_Store 41 | .AppleDouble 42 | .LSOverride 43 | 44 | # Icon must end with two \r 45 | Icon 46 | 47 | # Thumbnails 48 | ._* 49 | 50 | # Files that might appear in the root of a volume 51 | .DocumentRevisions-V100 52 | .fseventsd 53 | .Spotlight-V100 54 | .TemporaryItems 55 | .Trashes 56 | .VolumeIcon.icns 57 | .com.apple.timemachine.donotpresent 58 | 59 | # Directories potentially created on remote AFP share 60 | .AppleDB 61 | .AppleDesktop 62 | Network Trash Folder 63 | Temporary Items 64 | .apdisk 65 | 66 | ### Phoenix ### 67 | # Phoenix: a web framework for Elixir 68 | # App artifacts 69 | /_build 70 | /db 71 | /deps 72 | /*.ez 73 | /rel 74 | 75 | # Generated on crash by the VM 76 | erl_crash.dump 77 | 78 | # Generated on crash by NPM 79 | npm-debug.log 80 | 81 | # Static artifacts 82 | /assets/node_modules 83 | 84 | # Since we are building assets from assets/, 85 | # we ignore priv/static. You may want to comment 86 | # this depending on your deployment strategy. 87 | /priv/static/ 88 | 89 | # Files matching config/*.secret.exs pattern contain sensitive 90 | # data and you should not commit them into version control. 91 | # 92 | # Alternatively, you may comment the line below and commit the 93 | # secrets files as long as you replace their contents by environment 94 | # variables. 95 | /config/*.secret.exs 96 | 97 | ### SublimeText ### 98 | # cache files for sublime text 99 | *.tmlanguage.cache 100 | *.tmPreferences.cache 101 | *.stTheme.cache 102 | 103 | # workspace files are user-specific 104 | *.sublime-workspace 105 | 106 | # project files should be checked into the repository, unless a significant 107 | # proportion of contributors will probably not be using SublimeText 108 | # *.sublime-project 109 | 110 | # sftp configuration file 111 | sftp-config.json 112 | 113 | # Package control specific files 114 | Package Control.last-run 115 | Package Control.ca-list 116 | Package Control.ca-bundle 117 | Package Control.system-ca-bundle 118 | Package Control.cache/ 119 | Package Control.ca-certs/ 120 | Package Control.merged-ca-bundle 121 | Package Control.user-ca-bundle 122 | oscrypto-ca-bundle.crt 123 | bh_unicode_properties.cache 124 | 125 | # Sublime-github package stores a github token in this file 126 | # https://packagecontrol.io/packages/sublime-github 127 | GitHub.sublime-settings 128 | 129 | ### VisualStudioCode ### 130 | .vscode/* 131 | !.vscode/settings.json 132 | !.vscode/tasks.json 133 | !.vscode/launch.json 134 | !.vscode/extensions.json 135 | .history 136 | 137 | ### Windows ### 138 | # Windows thumbnail cache files 139 | Thumbs.db 140 | ehthumbs.db 141 | ehthumbs_vista.db 142 | 143 | # Folder config file 144 | Desktop.ini 145 | 146 | # Recycle Bin used on file shares 147 | $RECYCLE.BIN/ 148 | 149 | # Windows Installer files 150 | *.cab 151 | *.msi 152 | *.msm 153 | *.msp 154 | 155 | # Windows shortcuts 156 | *.lnk 157 | 158 | # End of https://www.gitignore.io/api/osx,linux,elixir,erlang,phoenix,windows,sublimetext,visualstudiocode 159 | 160 | .elixir_ls 161 | -------------------------------------------------------------------------------- /test/elixir_jobs/accounts/schemas/admin_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Accounts.Schemas.AdminTest do 2 | use ElixirJobs.DataCase 3 | 4 | alias ElixirJobs.Accounts.Schemas.Admin 5 | alias ElixirJobs.Repo 6 | 7 | describe "Admin.changeset/2" do 8 | test "validates correct data" do 9 | attrs = params_for(:admin) 10 | 11 | result = Admin.changeset(%Admin{}, attrs) 12 | 13 | assert %Ecto.Changeset{} = result 14 | assert result.valid? 15 | end 16 | 17 | required_attrs = [:name, :email, :password, :password_confirmation] 18 | 19 | Enum.each(required_attrs, fn attr -> 20 | test "validates that #{attr} is required" do 21 | attrs = 22 | :admin 23 | |> params_for() 24 | |> Map.delete(unquote(attr)) 25 | 26 | changeset = Admin.changeset(%Admin{}, attrs) 27 | 28 | refute changeset.valid? 29 | assert Enum.any?(changeset.errors, &(elem(&1, 0) == unquote(attr))) 30 | end 31 | end) 32 | 33 | test "encrypted_password is generated if password is created" do 34 | attrs = params_for(:admin) 35 | 36 | changeset = Admin.changeset(%Admin{}, attrs) 37 | 38 | assert changeset.valid? 39 | refute Ecto.Changeset.get_change(changeset, :encrypted_password) == nil 40 | 41 | assert Bcrypt.verify_pass( 42 | attrs[:password], 43 | Ecto.Changeset.get_change(changeset, :encrypted_password) 44 | ) 45 | end 46 | 47 | test "encrypted_password is generated if password is updated" do 48 | admin = insert(:admin) 49 | 50 | attrs = %{ 51 | password: "mynewpass", 52 | password_confirmation: "mynewpass" 53 | } 54 | 55 | changeset = Admin.changeset(admin, attrs) 56 | 57 | assert changeset.valid? 58 | refute Ecto.Changeset.get_change(changeset, :encrypted_password) == admin.encrypted_password 59 | 60 | assert Bcrypt.verify_pass( 61 | attrs[:password], 62 | Ecto.Changeset.get_change(changeset, :encrypted_password) 63 | ) 64 | end 65 | 66 | test "password is optional when user already has a encrypted one" do 67 | attrs = 68 | :admin 69 | |> params_for() 70 | |> Map.delete(:password) 71 | |> Map.delete(:password_confirmation) 72 | 73 | result = Admin.changeset(%Admin{encrypted_password: "supersecrethash"}, attrs) 74 | 75 | assert %Ecto.Changeset{} = result 76 | assert result.valid? 77 | end 78 | 79 | test "password_confirmation is required if password is received" do 80 | attrs = 81 | :admin 82 | |> params_for() 83 | |> Map.delete(:password_confirmation) 84 | 85 | changeset = Admin.changeset(%Admin{encrypted_password: "supersecrethash"}, attrs) 86 | 87 | assert %Ecto.Changeset{} = changeset 88 | refute changeset.valid? 89 | assert Enum.any?(changeset.errors, &(elem(&1, 0) == :password_confirmation)) 90 | end 91 | 92 | test "password_confirmation should match password" do 93 | attrs = params_for(:admin, password: "123456", password_confirmation: "67890") 94 | changeset = Admin.changeset(%Admin{}, attrs) 95 | 96 | assert %Ecto.Changeset{} = changeset 97 | refute changeset.valid? 98 | assert Enum.any?(changeset.errors, &(elem(&1, 0) == :password_confirmation)) 99 | end 100 | 101 | test "email should be unique" do 102 | admin = insert(:admin) 103 | attrs = params_for(:admin, email: admin.email) 104 | 105 | changeset = Admin.changeset(%Admin{}, attrs) 106 | 107 | assert %Ecto.Changeset{} = changeset 108 | assert changeset.valid? 109 | 110 | {:error, result} = Repo.insert(changeset) 111 | refute result.valid? 112 | assert Enum.any?(result.errors, &(elem(&1, 0) == :email)) 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /test/elixir_jobs/accounts/managers/admin_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Accounts.Managers.AdminTest do 2 | use ElixirJobs.DataCase 3 | 4 | alias ElixirJobs.Accounts.Managers.Admin, as: Manager 5 | alias ElixirJobs.Accounts.Schemas.Admin 6 | 7 | describe "Admin.list_admins/0" do 8 | test "returns all admins" do 9 | admin_1 = insert(:admin) 10 | admin_2 = insert(:admin) 11 | 12 | result = Manager.list_admins() 13 | 14 | assert Enum.any?(result, &(&1.id == admin_1.id)) 15 | assert Enum.any?(result, &(&1.id == admin_2.id)) 16 | end 17 | end 18 | 19 | describe "Admin.get_admin!/1" do 20 | test "returns the admin with given id" do 21 | admin_1 = insert(:admin) 22 | admin_2 = insert(:admin) 23 | 24 | result = Manager.get_admin!(admin_1.id) 25 | assert result.id == admin_1.id 26 | 27 | result = Manager.get_admin!(admin_2.id) 28 | assert result.id == admin_2.id 29 | end 30 | 31 | test "raises an exception if no admin is found with that ID" do 32 | assert_raise Ecto.NoResultsError, fn -> 33 | Manager.get_admin!(Ecto.UUID.generate()) 34 | end 35 | end 36 | end 37 | 38 | describe "Admin.get_admin_by_email!/1" do 39 | test "returns the admin with given id" do 40 | admin_1 = insert(:admin) 41 | admin_2 = insert(:admin) 42 | 43 | result = Manager.get_admin_by_email!(admin_1.email) 44 | assert result.id == admin_1.id 45 | 46 | result = Manager.get_admin_by_email!(admin_2.email) 47 | assert result.id == admin_2.id 48 | end 49 | 50 | test "raises an exception if no admin is found with that email" do 51 | assert_raise Ecto.NoResultsError, fn -> 52 | Manager.get_admin_by_email!("non-existent-@email.com") 53 | end 54 | end 55 | end 56 | 57 | describe "Admin.create_admin/1" do 58 | test "with valid data creates an portal" do 59 | admin_data = params_for(:admin) 60 | 61 | {result, resource} = Manager.create_admin(admin_data) 62 | 63 | assert result == :ok 64 | assert %Admin{} = resource 65 | assert %Admin{} = Manager.get_admin!(resource.id) 66 | end 67 | 68 | test "with invalid data returns error changeset" do 69 | admin_data = params_for(:admin, email: "") 70 | 71 | {result, resource} = Manager.create_admin(admin_data) 72 | 73 | assert result == :error 74 | assert %Ecto.Changeset{} = resource 75 | assert Enum.any?(resource.errors, fn {k, _v} -> k == :email end) 76 | end 77 | end 78 | 79 | describe "Admin.update_admin/2" do 80 | test "with valid data updates the admin" do 81 | admin = insert(:admin) 82 | 83 | new_admin_data = %{ 84 | email: "test@elixirjobs.net" 85 | } 86 | 87 | {result, resource} = Manager.update_admin(admin, new_admin_data) 88 | 89 | assert result == :ok 90 | assert %Admin{} = resource 91 | refute resource.email == admin.email 92 | assert resource.email == new_admin_data[:email] 93 | end 94 | 95 | test "with invalid data returns error changeset" do 96 | admin = insert(:admin) 97 | new_admin_data = %{email: ""} 98 | 99 | {result, resource} = Manager.update_admin(admin, new_admin_data) 100 | 101 | assert result == :error 102 | assert %Ecto.Changeset{} = resource 103 | assert Enum.any?(resource.errors, fn {k, _v} -> k == :email end) 104 | end 105 | end 106 | 107 | describe "Admin.delete_admin/1" do 108 | test "deletes the admin" do 109 | admin = insert(:admin) 110 | 111 | assert %Admin{} = Manager.get_admin!(admin.id) 112 | assert {:ok, admin} = Manager.delete_admin(admin) 113 | 114 | assert_raise Ecto.NoResultsError, fn -> 115 | Manager.get_admin!(admin.id) 116 | end 117 | end 118 | end 119 | 120 | describe "Admin.change_admin/1" do 121 | test "generates a changeset" do 122 | admin = insert(:admin) 123 | assert %Ecto.Changeset{} = Manager.change_admin(admin) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/controllers/admin/offer_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobsWeb.Admin.OfferController do 2 | use ElixirJobsWeb, :controller 3 | 4 | alias ElixirJobs.Core 5 | alias ElixirJobsWeb.Telegram 6 | alias ElixirJobsWeb.Twitter 7 | 8 | plug :scrub_params, "offer" when action in [:update] 9 | 10 | def index_published(conn, params) do 11 | page_number = 12 | with {:ok, page_no} <- Map.fetch(params, "page"), 13 | true <- is_binary(page_no), 14 | {value, _} <- Integer.parse(page_no) do 15 | value 16 | else 17 | _ -> 1 18 | end 19 | 20 | pages = Core.list_offers(published: true, page: page_number) 21 | 22 | conn 23 | |> assign(:offers, pages.entries) 24 | |> assign(:page_number, pages.page_number) 25 | |> assign(:total_pages, pages.total_pages) 26 | |> render("index_published.html") 27 | end 28 | 29 | def index_unpublished(conn, params) do 30 | page_number = 31 | with {:ok, page_no} <- Map.fetch(params, "page"), 32 | true <- is_binary(page_no), 33 | {value, _} <- Integer.parse(page_no) do 34 | value 35 | else 36 | _ -> 1 37 | end 38 | 39 | pages = Core.list_offers(published: false, page: page_number) 40 | 41 | conn 42 | |> assign(:offers, pages.entries) 43 | |> assign(:page_number, pages.page_number) 44 | |> assign(:total_pages, pages.total_pages) 45 | |> render("index_unpublished.html") 46 | end 47 | 48 | def publish(conn, %{"slug" => slug}) do 49 | slug 50 | |> Core.get_offer_by_slug!() 51 | |> Core.publish_offer() 52 | |> case do 53 | {:ok, _offer} -> 54 | conn 55 | |> put_flash(:info, gettext("Offer published correctly!")) 56 | |> redirect(to: offer_path(conn, :show, slug)) 57 | 58 | {:error, _} -> 59 | conn 60 | |> put_flash(:info, gettext("An error occurred while publishing the offer")) 61 | |> redirect(to: admin_offer_path(conn, :index_unpublished)) 62 | end 63 | end 64 | 65 | def send_twitter(conn, %{"slug" => slug}) do 66 | offer = Core.get_offer_by_slug!(slug) 67 | 68 | Twitter.publish(conn, offer) 69 | 70 | conn 71 | |> put_flash(:info, gettext("Offer correctly sent to Twitter account!")) 72 | |> redirect(to: offer_path(conn, :show, slug)) 73 | end 74 | 75 | def send_telegram(conn, %{"slug" => slug}) do 76 | offer = Core.get_offer_by_slug!(slug) 77 | 78 | case Telegram.send(conn, offer) do 79 | :ok -> 80 | conn 81 | |> put_flash(:info, gettext("Offer correctly sent to Telegram channel!")) 82 | |> redirect(to: offer_path(conn, :show, slug)) 83 | 84 | error -> 85 | raise error 86 | end 87 | end 88 | 89 | def edit(conn, %{"slug" => slug}) do 90 | offer = Core.get_offer_by_slug!(slug) 91 | offer_changeset = Core.change_offer(offer) 92 | 93 | render(conn, "edit.html", changeset: offer_changeset, offer: offer) 94 | end 95 | 96 | def update(conn, %{"slug" => slug, "offer" => offer_params}) do 97 | offer = Core.get_offer_by_slug!(slug) 98 | 99 | case Core.update_offer(offer, offer_params) do 100 | {:ok, offer} -> 101 | conn 102 | |> put_flash(:info, gettext("Job offer updated correctly!")) 103 | |> redirect(to: offer_path(conn, :show, offer.slug)) 104 | 105 | {:error, changeset} -> 106 | render(conn, "edit.html", changeset: changeset, offer: offer) 107 | end 108 | end 109 | 110 | def delete(conn, %{"slug" => slug}) do 111 | slug 112 | |> Core.get_offer_by_slug!() 113 | |> Core.delete_offer() 114 | |> case do 115 | {:ok, _} -> 116 | conn 117 | |> put_flash(:info, gettext("Job offer removed correctly!")) 118 | |> redirect(to: admin_offer_path(conn, :index_published)) 119 | 120 | {:error, _} -> 121 | conn 122 | |> put_flash(:error, gettext("Job offer couldn't be removed correctly!")) 123 | |> redirect(to: offer_path(conn, :show, slug)) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/elixir_jobs/core/managers/offer.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirJobs.Core.Managers.Offer do 2 | @moduledoc """ 3 | The Offer monoger. 4 | """ 5 | 6 | alias ElixirJobs.Core.Queries.Offer, as: OfferQuery 7 | alias ElixirJobs.Core.Schemas.Offer 8 | alias ElixirJobs.Repo 9 | 10 | @doc """ 11 | Returns the list of offers. 12 | 13 | Accepts some options: 14 | 15 | - page: number of the page. If not passed, no pagination is made. 16 | - published: returns only published or unpublished offers. 17 | - job_place: filter offers by job place 18 | - job_type: filter offers by job type 19 | - search_text: filter offers by text 20 | 21 | ## Examples 22 | 23 | iex> list_offers() 24 | [%Offer{}, ...] 25 | 26 | iex> list_offers(page_no) 27 | [%Offer{}, ...] 28 | 29 | """ 30 | def list_offers(opts \\ Keyword.new()) do 31 | query = OfferQuery.build(Offer, opts) 32 | 33 | case Keyword.get(opts, :page) do 34 | page_no when is_integer(page_no) and page_no > 0 -> 35 | Repo.paginate(query, page: page_no) 36 | 37 | _ -> 38 | Repo.all(query) 39 | end 40 | end 41 | 42 | @doc """ 43 | Gets a single offer. 44 | 45 | Raises `Ecto.NoResultsError` if the Offer does not exist. 46 | 47 | Accepts some options: 48 | 49 | - published: returns only published or unpublished offers. 50 | - job_place: filter offers by job place 51 | - job_type: filter offers by job type 52 | 53 | ## Examples 54 | 55 | iex> get_offer!(123) 56 | %Offer{} 57 | 58 | iex> get_offer!(456) 59 | ** (Ecto.NoResultsError) 60 | 61 | """ 62 | def get_offer!(id, opts \\ Keyword.new()) do 63 | Offer 64 | |> OfferQuery.by_id(id) 65 | |> OfferQuery.build(opts) 66 | |> Repo.one!() 67 | end 68 | 69 | @doc """ 70 | Gets a single offer by it's slug. 71 | 72 | Raises `Ecto.NoResultsError` if the Offer does not exist. 73 | 74 | Accepts some options: 75 | 76 | - published: returns only published or unpublished offers. 77 | - job_place: filter offers by job place 78 | - job_type: filter offers by job type 79 | 80 | ## Examples 81 | 82 | iex> get_offer_by_slug!("existing-slug") 83 | %Offer{} 84 | 85 | iex> get_offer_by_slug!("non-existent-slug") 86 | ** (Ecto.NoResultsError) 87 | 88 | """ 89 | def get_offer_by_slug!(slug, opts \\ Keyword.new()) do 90 | Offer 91 | |> OfferQuery.by_slug(slug) 92 | |> OfferQuery.build(opts) 93 | |> Repo.one!() 94 | end 95 | 96 | @doc """ 97 | Creates a offer. 98 | 99 | ## Examples 100 | 101 | iex> create_offer(%{field: value}) 102 | {:ok, %Offer{}} 103 | 104 | iex> create_offer(%{field: bad_value}) 105 | {:error, %Ecto.Changeset{}} 106 | 107 | """ 108 | def create_offer(attrs \\ %{}) do 109 | %Offer{} 110 | |> Offer.changeset(attrs) 111 | |> Repo.insert() 112 | end 113 | 114 | @doc """ 115 | Updates a offer. 116 | 117 | ## Examples 118 | 119 | iex> update_offer(offer, %{field: new_value}) 120 | {:ok, %Offer{}} 121 | 122 | iex> update_offer(offer, %{field: bad_value}) 123 | {:error, %Ecto.Changeset{}} 124 | 125 | """ 126 | def update_offer(%Offer{} = offer, attrs) do 127 | offer 128 | |> Offer.changeset(attrs) 129 | |> Repo.update() 130 | end 131 | 132 | @doc """ 133 | Publishes an offer. 134 | Optionally you can provide a publication date 135 | 136 | ## Examples 137 | 138 | iex> publish_offer(offer) 139 | {:ok, %Offer{}} 140 | 141 | iex> publish_offer(offer, datetime) 142 | {:ok, %Offer{}} 143 | 144 | """ 145 | def publish_offer(%Offer{} = offer), do: publish_offer(offer, DateTime.utc_now()) 146 | 147 | def publish_offer(%Offer{} = offer, date) do 148 | update_offer(offer, %{published_at: date}) 149 | end 150 | 151 | @doc """ 152 | Deletes a Offer. 153 | 154 | ## Examples 155 | 156 | iex> delete_offer(offer) 157 | {:ok, %Offer{}} 158 | 159 | iex> delete_offer(offer) 160 | {:error, %Ecto.Changeset{}} 161 | 162 | """ 163 | def delete_offer(%Offer{} = offer) do 164 | Repo.delete(offer) 165 | end 166 | 167 | @doc """ 168 | Returns an `%Ecto.Changeset{}` for tracking offer changes. 169 | 170 | ## Examples 171 | 172 | iex> change_offer(offer) 173 | %Ecto.Changeset{source: %Offer{}} 174 | 175 | """ 176 | def change_offer(%Offer{} = offer) do 177 | Offer.changeset(offer, %{}) 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /lib/elixir_jobs_web/templates/offer/_form.html.eex: -------------------------------------------------------------------------------- 1 |
2 | <%= form_for @changeset, @action, fn f -> %> 3 |
4 |
5 | 6 |
7 |
8 |
9 | <%= text_input(f, :title, 10 | id: "offer_title", 11 | class: "input is-large", 12 | placeholder: gettext("Title goes here (will be tweeted)")) %> 13 | <%= error_tag(f, :title) %> 14 |
15 |
16 |
17 |
18 |
19 | 20 |
21 |
22 |
23 | <%= text_input(f, :company, 24 | id: "offer_company", 25 | class: "input is-medium", 26 | placeholder: gettext("Company name")) %> 27 | <%= error_tag(f, :company) %> 28 |
29 |
30 | <%= text_input(f, :location, 31 | id: "offer_location", 32 | class: "input is-medium", 33 | placeholder: gettext("Location of the job")) %> 34 | <%= error_tag(f, :location) %> 35 |
36 |
37 |
38 | 39 |
40 |
41 | 42 |
43 |
44 |
45 | <%= text_input(f, :url, 46 | id: "offer_url", 47 | class: "input", 48 | placeholder: gettext("URL of the job offer")) %> 49 | <%= error_tag(f, :url) %> 50 |
51 |
52 |
53 | 54 |
55 |
56 | 57 |
58 |
59 |
60 |
61 | <%= select(f, :job_place, get_job_place_options(gettext("Select a job place")), class: "select") %> 62 |
63 |
64 |
65 |
66 | <%= select(f, :job_type, get_job_type_options(gettext("Select a job type")), class: "select") %> 67 |
68 |
69 |
70 |
71 | 72 |
73 |
74 |
75 |
76 |
77 | <%= textarea(f, :summary, 78 | rows: 3, 79 | maxlength: 450, 80 | id: "offer_summary", 81 | class: "textarea", 82 | placeholder: gettext("Summary (max 2000 characters)")) %> 83 | <%= error_tag(f, :summary) %> 84 |
85 |
86 |
87 | 88 |
89 |
90 | 91 |
92 |
93 |
94 | <%= text_input(f, :contact_email, 95 | id: "offer_contact_email", 96 | class: "input", 97 | placeholder: gettext("Your e-mail address")) %> 98 | <%= error_tag(f, :contact_email) %> 99 |

<%= raw gettext(" We will use this in case we need to contact you regarding this job posting. It will not be public.") %>

100 |
101 |
102 |
103 | 104 | 105 |
106 |
107 | 108 |
109 |
110 |
111 |
112 | 113 | <%= submit(gettext("Submit"), class: "button is-info is-medium") %> 114 |
115 |
116 |
117 |
118 | <% end %> 119 | 120 | 121 |
122 | --------------------------------------------------------------------------------