diff --git a/.DS_Store b/.DS_Store index 14f7fcd..a246cb8 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/assets/js/app.js b/assets/js/app.js index df0cdd9..00f387e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,3 +1,6 @@ +// We import the CSS which is extracted to its own file by esbuild. +// Remove this line if you add a your own CSS build pipeline (e.g postcss). + // If you want to use Phoenix channels, run `mix help phx.gen.channel` // to get started and then uncomment the line below. // import "./user_socket.js" @@ -16,26 +19,53 @@ // // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. -import "phoenix_html" +import "phoenix_html"; // Establish Phoenix Socket and LiveView configuration. -import {Socket} from "phoenix" -import {LiveSocket} from "phoenix_live_view" -import topbar from "../vendor/topbar" +import { Socket } from "phoenix"; +import { LiveSocket } from "phoenix_live_view"; +import topbar from "../vendor/topbar"; +let Hooks = {}; +Hooks.QrCode = { + mounted() { + var qrcode = new QRCode(document.getElementById("qrcode"), { + width: 180, + height: 180, + }); + + function makeCode() { + var elText = document.getElementById("ticketid"); + + if (!elText.value) { + alert("Input a text"); + elText.focus(); + return; + } -let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") -let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) + qrcode.makeCode("http://5.189.162.107:3200/ticket/" + elText.value); + } + + makeCode(); + }, +}; + +let csrfToken = document + .querySelector("meta[name='csrf-token']") + .getAttribute("content"); +let liveSocket = new LiveSocket("/live", Socket, { + hooks: Hooks, + params: { _csrf_token: csrfToken }, +}); // Show progress bar on live navigation and form submits -topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) -window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) -window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); +window.addEventListener("phx:page-loading-start", (info) => topbar.show()); +window.addEventListener("phx:page-loading-stop", (info) => topbar.hide()); // connect if there are any LiveViews on the page -liveSocket.connect() +liveSocket.connect(); // expose liveSocket on window for web console debug logs and latency simulation: // >> liveSocket.enableDebug() // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session // >> liveSocket.disableLatencySim() -window.liveSocket = liveSocket - +window.liveSocket = liveSocket; diff --git a/config/test.exs b/config/test.exs index 4dbe051..21b19df 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,5 +1,8 @@ import Config +# Only in tests, remove the complexity from the password hashing algorithm +config :bcrypt_elixir, :log_rounds, 1 + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used diff --git a/lib/.DS_Store b/lib/.DS_Store new file mode 100644 index 0000000..0dbdda6 Binary files /dev/null and b/lib/.DS_Store differ diff --git a/lib/elixir_conf_africa/.DS_Store b/lib/elixir_conf_africa/.DS_Store new file mode 100644 index 0000000..83f37be Binary files /dev/null and b/lib/elixir_conf_africa/.DS_Store differ diff --git a/lib/elixir_conf_africa/accounts.ex b/lib/elixir_conf_africa/accounts.ex new file mode 100644 index 0000000..cbc2b77 --- /dev/null +++ b/lib/elixir_conf_africa/accounts.ex @@ -0,0 +1,382 @@ +defmodule ElixirConfAfrica.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias ElixirConfAfrica.Accounts.{User, UserNotifier, UserToken} + alias ElixirConfAfrica.Repo + + ## Database getters + + @doc """ + Gets a user by email. + + ## Examples + + iex> get_user_by_email("foo@example.com") + %User{} + + iex> get_user_by_email("unknown@example.com") + nil + + """ + + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc """ + Lists all users apart from the current user. + """ + + def list_users_apart_from_current_user(current_user) do + Repo.all( + from(u in User, + where: u.id != ^current_user.id, + order_by: u.email, + select: u + ) + ) + end + + @doc """ + Updates a user role. + + """ + def update_user_role(%User{} = user, role) do + user + |> User.role_changeset(%{role: role}) + |> Repo.update() + end + + def delete_user_session_token(token) do + Repo.delete_all(UserToken.token_and_context_query(token, "session")) + :ok + end + + @doc """ + Gets a user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user_registration(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs, hash_password: false) + end + + ## Settings + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + ## Examples + + iex> change_user_email(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_email(user, attrs \\ %{}) do + User.email_changeset(user, attrs) + end + + @doc """ + Emulates that the email will change without actually changing + it in the database. + + ## Examples + + iex> apply_user_email(user, "valid password", %{email: ...}) + {:ok, %User{}} + + iex> apply_user_email(user, "invalid password", %{email: ...}) + {:error, %Ecto.Changeset{}} + + """ + def apply_user_email(user, password, attrs) do + user + |> User.email_changeset(attrs) + |> User.validate_current_password(password) + |> Ecto.Changeset.apply_action(:update) + end + + @doc """ + Updates the user email using the given token. + + If the token matches, the user email is updated and the token is deleted. + The confirmed_at date is also updated to the current time. + """ + def update_user_email(user, token) do + context = "change:#{user.email}" + + with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), + %UserToken{sent_to: email} <- Repo.one(query), + {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do + :ok + else + _ -> :error + end + end + + defp user_email_multi(user, email, context) do + changeset = + user + |> User.email_changeset(%{email: email}) + |> User.confirm_changeset() + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) + end + + @doc """ + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") + + Repo.insert!(user_token) + UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user password. + + ## Examples + + iex> change_user_password(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_password(user, attrs \\ %{}) do + User.password_changeset(user, attrs, hash_password: false) + end + + @doc """ + Updates the user password. + + ## Examples + + iex> update_user_password(user, "valid password", %{password: ...}) + {:ok, %User{}} + + iex> update_user_password(user, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_user_password(user, password, attrs) do + changeset = + user + |> User.password_changeset(attrs) + |> User.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_session_token(token) do + Repo.delete_all(UserToken.token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc """ + Delivers the confirmation email instructions to the given user. + + ## Examples + + iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :edit, &1)) + {:error, :already_confirmed} + + """ + def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if user.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(user_token) + UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a user by the given token. + + If the token matches, the user account is marked as confirmed + and the token is deleted. + """ + def confirm_user(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), + %User{} = user <- Repo.one(query), + {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do + {:ok, user} + else + _ -> :error + end + end + + defp confirm_user_multi(user) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.confirm_changeset(user)) + |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) + end + + ## Reset password + + @doc """ + Delivers the reset password email to the given user. + + ## Examples + + iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") + Repo.insert!(user_token) + UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the user by reset password token. + + ## Examples + + iex> get_user_by_reset_password_token("validtoken") + %User{} + + iex> get_user_by_reset_password_token("invalidtoken") + nil + + """ + def get_user_by_reset_password_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), + %User{} = user <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Resets the user password. + + ## Examples + + iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) + {:ok, %User{}} + + iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) + {:error, %Ecto.Changeset{}} + + """ + def reset_user_password(user, attrs) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) + |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end +end diff --git a/lib/elixir_conf_africa/accounts/user.ex b/lib/elixir_conf_africa/accounts/user.ex new file mode 100644 index 0000000..f12c7ca --- /dev/null +++ b/lib/elixir_conf_africa/accounts/user.ex @@ -0,0 +1,148 @@ +defmodule ElixirConfAfrica.Accounts.User do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "users" do + field :email, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :confirmed_at, :naive_datetime + field :role, :string, default: "user" + + timestamps() + end + + @doc """ + A user changeset for registration. + + It is important to validate the length of both email and password. + Otherwise databases may truncate the email without warnings, which + could lead to unpredictable or insecure behaviour. Long passwords may + also be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def registration_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email, :password, :role]) + |> validate_email() + |> validate_password(opts) + end + + def role_changeset(user, attrs) do + user + |> cast(attrs, [:role]) + |> validate_inclusion(:role, ["user", "admin", "scanner"]) + end + + defp validate_email(changeset) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> unsafe_validate_unique(:email, ElixirConfAfrica.Repo) + |> unique_constraint(:email) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 6, max: 72) + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + @doc """ + A user changeset for changing the email. + + It requires the email to change otherwise an error is added. + """ + def email_changeset(user, attrs) do + user + |> cast(attrs, [:email]) + |> validate_email() + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A user changeset for changing the password. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + change(user, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%ElixirConfAfrica.Accounts.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end + + @doc """ + Validates the current password otherwise adds an error to the changeset. + """ + def validate_current_password(changeset, password) do + if valid_password?(changeset.data, password) do + changeset + else + add_error(changeset, :current_password, "is not valid") + end + end +end diff --git a/lib/elixir_conf_africa/accounts/user_notifier.ex b/lib/elixir_conf_africa/accounts/user_notifier.ex new file mode 100644 index 0000000..2652841 --- /dev/null +++ b/lib/elixir_conf_africa/accounts/user_notifier.ex @@ -0,0 +1,80 @@ +defmodule ElixirConfAfrica.Accounts.UserNotifier do + @moduledoc false + import Swoosh.Email + + alias ElixirConfAfrica.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Elixirconf", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to confirm account. + """ + def deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to reset a user password. + """ + def deliver_reset_password_instructions(user, url) do + deliver(user.email, "Reset password instructions", """ + + ============================== + + Hi #{user.email}, + + You can reset your password by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to update a user email. + """ + def deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end +end diff --git a/lib/elixir_conf_africa/accounts/user_token.ex b/lib/elixir_conf_africa/accounts/user_token.ex new file mode 100644 index 0000000..1822922 --- /dev/null +++ b/lib/elixir_conf_africa/accounts/user_token.ex @@ -0,0 +1,180 @@ +defmodule ElixirConfAfrica.Accounts.UserToken do + @moduledoc false + use Ecto.Schema + import Ecto.Query + alias ElixirConfAfrica.Accounts.UserToken + + @hash_algorithm :sha256 + @rand_size 32 + + # It is very important to keep the reset password token expiry short, + # since someone with access to the email may take over the account. + @reset_password_validity_in_days 1 + @confirm_validity_in_days 7 + @change_email_validity_in_days 7 + @session_validity_in_days 60 + + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :user, ElixirConfAfrica.Accounts.User + + timestamps(updated_at: false) + end + + @doc """ + Generates a token that will be stored in a signed place, + such as session or cookie. As they are signed, those + tokens do not need to be hashed. + + The reason why we store session tokens in the database, even + though Phoenix already provides a session cookie, is because + Phoenix' default session cookies are not persisted, they are + simply signed and potentially encrypted. This means they are + valid indefinitely, unless you change the signing/encryption + salt. + + Therefore, storing them allows individual user + sessions to be expired. The token system can also be extended + to store additional data, such as the device used for logging in. + You could then use this information to display all valid sessions + and devices in the UI and allow users to explicitly expire any + session they deem invalid. + """ + def build_session_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %UserToken{token: token, context: "session", user_id: user.id}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The token is valid if it matches the value in the database and it has + not expired (after @session_validity_in_days). + """ + def verify_session_token_query(token) do + query = + from token in token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: user + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the user's email. + + The non-hashed token is sent to the user email while the + hashed part is stored in the database. The original token cannot be reconstructed, + which means anyone with read-only access to the database cannot directly use + the token in the application to gain access. Furthermore, if the user changes + their email in the system, the tokens sent to the previous email are no longer + valid. + + Users can easily adapt the existing code to provide other types of delivery methods, + for example, by phone numbers. + """ + def build_email_token(user, context) do + build_hashed_token(user, context, user.email) + end + + defp build_hashed_token(user, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %UserToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + user_id: user.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The given token is valid if it matches its hashed counterpart in the + database and the user email has not changed. This function also checks + if the token is being used within a certain period, depending on the + context. The default contexts supported by this function are either + "confirm", for account confirmation emails, and "reset_password", + for resetting the password. For verifying requests to change the email, + see `verify_change_email_token_query/2`. + """ + def verify_email_token_query(token, context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + days = days_for_context(context) + + query = + from token in token_and_context_query(hashed_token, context), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, + select: user + + {:ok, query} + + :error -> + :error + end + end + + defp days_for_context("confirm"), do: @confirm_validity_in_days + defp days_for_context("reset_password"), do: @reset_password_validity_in_days + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + This is used to validate requests to change the user + email. It is different from `verify_email_token_query/2` precisely because + `verify_email_token_query/2` validates the email has not changed, which is + the starting point by this function. + + The given token is valid if it matches its hashed counterpart in the + database and if it has not expired (after @change_email_validity_in_days). + The context must always start with "change:". + """ + def verify_change_email_token_query(token, "change:" <> _ = context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(@change_email_validity_in_days, "day") + + {:ok, query} + + :error -> + :error + end + end + + @doc """ + Returns the token struct for the given token value and context. + """ + def token_and_context_query(token, context) do + from UserToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given user for the given contexts. + """ + def user_and_contexts_query(user, :all) do + from t in UserToken, where: t.user_id == ^user.id + end + + def user_and_contexts_query(user, [_ | _] = contexts) do + from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts + end +end diff --git a/lib/elixir_conf_africa/emails.ex b/lib/elixir_conf_africa/emails.ex new file mode 100644 index 0000000..73437b2 --- /dev/null +++ b/lib/elixir_conf_africa/emails.ex @@ -0,0 +1,41 @@ +defmodule ElixirConfAfrica.Emails do + @moduledoc """ + The Emails module is responsible for all the interactions with the Sendgrid API + """ + + @doc """ + Delivers a ticket by email + """ + + def deliver_ticket_by_email(email, url) do + header = [ + {"Authorization", + "Bearer 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"}, + {"Content-Type", "application/json"}, + {"Accept", "application/json"} + ] + + body = %{ + "from" => %{ + "email" => "Mche " + }, + "personalizations" => [ + %{ + "to" => [ + %{"email" => email} + ], + "dynamic_template_data" => %{ + "url" => url + } + } + ], + "template_id" => "d-0b163cbd94794e5ca1757b26b38f76dc" + } + + HTTPoison.post( + "https://api.sendgrid.com/v3/mail/send", + Jason.encode!(body), + header + ) + end +end diff --git a/lib/elixir_conf_africa/events.ex b/lib/elixir_conf_africa/events.ex deleted file mode 100644 index 65431c0..0000000 --- a/lib/elixir_conf_africa/events.ex +++ /dev/null @@ -1,139 +0,0 @@ -defmodule ElixirConfAfrica.Events do - @moduledoc """ - The Events context. - """ - - import Ecto.Query, warn: false - - alias ElixirConfAfrica.Events.Event - alias ElixirConfAfrica.Repo - alias ElixirConfAfrica.TicketTypes.TicketType - - @doc """ - Returns the list of events. - - ## Examples - - iex> list_events() - [%Event{}, ...] - - """ - @spec list_events() :: list() - def list_events do - Repo.all(from e in Event, order_by: [desc: e.id]) - end - - @doc """ - Returns the elixir conf event together with all its ticket types - """ - @spec get_event_with_ticket_types_by_event_name(String.t()) :: Event.t() - def get_event_with_ticket_types_by_event_name(event_name) do - query = - from event in Event, - join: ticket_types in assoc(event, :ticket_types), - where: event.name == ^event_name, - preload: [ticket_types: ticket_types] - - Repo.one(query) - end - - @doc """ - Get totals number of available tickets for a given event - """ - @spec get_total_number_of_available_tickets(String.t()) :: Event.t() - def get_total_number_of_available_tickets(event_name) do - query = - from t in TicketType, - join: e in Event, - on: t.event_id == e.id and e.name == ^event_name, - select: sum(t.number) - - Repo.one(query) - end - - @doc """ - Gets a single event. - - Raises `Ecto.NoResultsError` if the Event does not exist. - - ## Examples - - iex> get_event!(123) - %Event{} - - iex> get_event!(456) - ** (Ecto.NoResultsError) - - """ - @spec get_event!(non_neg_integer()) :: Event.t() | Ecto.NoResultsError - def get_event!(id), do: Repo.get!(Event, id) - - @doc """ - Creates a event. - - ## Examples - - iex> create_event(%{field: value}) - {:ok, %Event{}} - - iex> create_event(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - @spec create_event(map()) :: {:ok, Event.t()} | {:error, Ecto.Changeset.t()} - def create_event(attrs \\ %{}) do - %Event{} - |> Event.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a event. - - ## Examples - - iex> update_event(event, %{field: new_value}) - {:ok, %Event{}} - - iex> update_event(event, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - @spec update_event(Event.t(), map()) :: {:ok, Event.t()} | {:error, Ecto.Changeset.t()} - def update_event(%Event{} = event, attrs) do - event - |> Event.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a event. - - ## Examples - - iex> delete_event(event) - {:ok, %Event{}} - - iex> delete_event(event) - {:error, %Ecto.Changeset{}} - - """ - @spec delete_event(Event.t()) :: {:ok, Event.t()} | {:error, Ecto.Changeset.t()} - def delete_event(%Event{} = event) do - Repo.delete(event) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking event changes. - - ## Examples - - iex> change_event(event) - %Ecto.Changeset{data: %Event{}} - - """ - @spec change_event(Event.t(), map()) :: Ecto.Changeset.t() - def change_event(%Event{} = event, attrs \\ %{}) do - Event.changeset(event, attrs) - end -end diff --git a/lib/elixir_conf_africa/events/event.ex b/lib/elixir_conf_africa/events/event.ex deleted file mode 100644 index 92731df..0000000 --- a/lib/elixir_conf_africa/events/event.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule ElixirConfAfrica.Events.Event do - @moduledoc false - use TypedEctoSchema - import Ecto.Changeset - - typed_schema "events" do - field :name, :string - field :description, :string - field :location, :string - field :event_type, :string - field :start_date, :naive_datetime - field :end_date, :naive_datetime - has_many :ticket_types, ElixirConfAfrica.TicketTypes.TicketType - - timestamps() - end - - @doc false - def changeset(event, attrs) do - event - |> cast(attrs, [:name, :event_type, :location, :description, :start_date, :end_date]) - |> validate_required([:name, :event_type, :location, :description, :start_date, :end_date]) - end -end diff --git a/lib/elixir_conf_africa/paystack.ex b/lib/elixir_conf_africa/paystack.ex new file mode 100644 index 0000000..6b7a870 --- /dev/null +++ b/lib/elixir_conf_africa/paystack.ex @@ -0,0 +1,107 @@ +defmodule ElixirConfAfrica.Paystack do + @moduledoc """ + The Paystack module is responsible for all the interactions with the Paystack API + """ + defp api_url, do: "https://api.paystack.co/transaction/initialize" + + defp paystack_headers, + do: [ + { + "Content-Type", + "application/json" + }, + { + "Authorization", + "Bearer #{api_key()}" + } + ] + + @doc """ + Initializes a transaction with Paystack and returns the transaction reference and a url to redirect to + + """ + def initialize(email, amount) do + api_url = api_url() + + amount = amount * 100 + + paystack_body = + %{ + "email" => email, + "amount" => amount, + "callback_url" => "http://localhost:5800/success" + } + |> Jason.encode!() + + case HTTPoison.post(api_url, paystack_body, paystack_headers()) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + body + |> Jason.decode!() + |> Map.get("data") + + {:error, %HTTPoison.Error{reason: reason}} -> + reason + end + end + + @doc """ + Verifies a transaction with Paystack and returns the transaction details + + """ + def test_verification(transaction_reference) do + paystack_headers = paystack_headers() + + url = "https://api.paystack.co/transaction/verify/#{transaction_reference}" + + case HTTPoison.get(url, paystack_headers) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + body + |> Jason.decode!() + |> Map.get("data") + + {:error, %HTTPoison.Error{reason: reason}} -> + reason + end + end + + @doc """ + Lists all transactions made + + """ + def list_transactions do + get_transactions() + |> Enum.map(fn transaction -> + %{ + "reference" => transaction["reference"], + "amount" => transaction["amount"], + "status" => transaction["status"], + "currency" => transaction["currency"], + "paid_at" => transaction["paid_at"], + "email" => transaction["customer"]["email"], + "bank" => transaction["authorization"]["bank"] + } + end) + end + + defp get_transactions do + url = "https://api.paystack.co/transaction" + + case HTTPoison.get(url, paystack_headers()) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + body + |> Jason.decode!() + |> Map.get("data") + + {:error, %HTTPoison.Error{reason: reason}} -> + reason + end + end + + defp api_key do + if Mix.env() == :test do + "sk_test_46828225be64577ea7523018d51bb119d00d4e40" + else + "sk_test_46828225be64577ea7523018d51bb119d00d4e40" + end + end +end diff --git a/lib/elixir_conf_africa/ticket_types.ex b/lib/elixir_conf_africa/ticket_types.ex index cccb8ad..0432c77 100644 --- a/lib/elixir_conf_africa/ticket_types.ex +++ b/lib/elixir_conf_africa/ticket_types.ex @@ -6,6 +6,7 @@ defmodule ElixirConfAfrica.TicketTypes do import Ecto.Query, warn: false alias ElixirConfAfrica.Repo + alias ElixirConfAfrica.Tickets.Ticket alias ElixirConfAfrica.TicketTypes.TicketType @doc """ @@ -17,11 +18,33 @@ defmodule ElixirConfAfrica.TicketTypes do [%TicketType{}, ...] """ - @spec list_ticket_types() :: list() def list_ticket_types do Repo.all(TicketType) end + @doc """ + Returns the list of ticket_types with the remaining tickets. + + + """ + + def list_ticket_types_with_remaining_tickets do + Repo.all( + from(tick in TicketType, + left_join: t in Ticket, + on: t.ticket_type_id == tick.id and t.is_paid == true and t.is_refunded == false, + group_by: tick.id, + select: %{ + id: tick.id, + name: tick.name, + remaining_tickets: coalesce(tick.number - count(t.id), 0), + description: tick.description, + price: tick.price + } + ) + ) + end + @doc """ Gets a single ticket_type. @@ -36,7 +59,6 @@ defmodule ElixirConfAfrica.TicketTypes do ** (Ecto.NoResultsError) """ - @spec get_ticket_type!(non_neg_integer()) :: TicketType.t() | Ecto.NoResultsError def get_ticket_type!(id), do: Repo.get!(TicketType, id) @doc """ @@ -51,7 +73,6 @@ defmodule ElixirConfAfrica.TicketTypes do {:error, %Ecto.Changeset{}} """ - @spec create_ticket_type(map()) :: {:ok, TicketType.t()} | {:error, Ecto.Changeset.t()} def create_ticket_type(attrs \\ %{}) do %TicketType{} |> TicketType.changeset(attrs) @@ -70,8 +91,6 @@ defmodule ElixirConfAfrica.TicketTypes do {:error, %Ecto.Changeset{}} """ - @spec update_ticket_type(TicketType.t(), map()) :: - {:ok, TicketType.t()} | {:error, Ecto.Changeset.t()} def update_ticket_type(%TicketType{} = ticket_type, attrs) do ticket_type |> TicketType.changeset(attrs) @@ -90,7 +109,6 @@ defmodule ElixirConfAfrica.TicketTypes do {:error, %Ecto.Changeset{}} """ - @spec delete_ticket_type(TicketType.t()) :: {:ok, TicketType.t()} | {:error, Ecto.Changeset.t()} def delete_ticket_type(%TicketType{} = ticket_type) do Repo.delete(ticket_type) end @@ -104,7 +122,6 @@ defmodule ElixirConfAfrica.TicketTypes do %Ecto.Changeset{data: %TicketType{}} """ - @spec change_ticket_type(TicketType.t(), map()) :: Ecto.Changeset.t() def change_ticket_type(%TicketType{} = ticket_type, attrs \\ %{}) do TicketType.changeset(ticket_type, attrs) end diff --git a/lib/elixir_conf_africa/ticket_types/ticket_type.ex b/lib/elixir_conf_africa/ticket_types/ticket_type.ex index c89aa01..a7b7243 100644 --- a/lib/elixir_conf_africa/ticket_types/ticket_type.ex +++ b/lib/elixir_conf_africa/ticket_types/ticket_type.ex @@ -1,15 +1,14 @@ defmodule ElixirConfAfrica.TicketTypes.TicketType do @moduledoc false - - use TypedEctoSchema + use Ecto.Schema import Ecto.Changeset - typed_schema "ticket_types" do + schema "ticket_types" do field :name, :string field :description, :string - field :price, :decimal + field :price, :integer field :number, :integer - belongs_to :event, ElixirConfAfrica.Events.Event + has_many :tickets, ElixirConfAfrica.Tickets.Ticket timestamps() end @@ -17,8 +16,8 @@ defmodule ElixirConfAfrica.TicketTypes.TicketType do @doc false def changeset(ticket_type, attrs) do ticket_type - |> cast(attrs, [:event_id, :name, :description, :price, :number]) - |> validate_required([:event_id, :name, :description, :price, :number]) - |> foreign_key_constraint(:event_id) + |> cast(attrs, [:name, :description, :price, :number]) + |> validate_required([:name, :description, :price, :number]) + |> unique_constraint(:name) end end diff --git a/lib/elixir_conf_africa/tickets.ex b/lib/elixir_conf_africa/tickets.ex new file mode 100644 index 0000000..492983a --- /dev/null +++ b/lib/elixir_conf_africa/tickets.ex @@ -0,0 +1,148 @@ +defmodule ElixirConfAfrica.Tickets do + @moduledoc """ + The Tickets context. + """ + + import Ecto.Query, warn: false + alias ElixirConfAfrica.Repo + + alias ElixirConfAfrica.Tickets.Ticket + + @doc """ + Returns the list of ticket. + + ## Examples + + iex> list_ticket() + [%Ticket{}, ...] + + """ + def list_ticket do + Repo.all(Ticket) + end + + @doc """ + Gets a single ticket by ticketid. + + """ + + def get_ticket_by_ticketid!(ticketid) do + Ticket + |> Repo.get_by!(ticketid: ticketid) + |> Repo.preload(:ticket_type) + end + + @doc """ + List paid tickets. + + """ + def list_paid_tickets do + Ticket + |> where([t], t.is_paid == true and t.is_refunded == false) + |> Repo.all() + |> Repo.preload(:ticket_type) + end + + @doc """ + List refunded tickets. + + """ + def list_refunded_tickets do + Ticket + |> where([t], t.is_refunded == true) + |> Repo.all() + |> Repo.preload(:ticket_type) + end + + @doc """ + List unpaid tickets. + + """ + def list_unpaid_tickets do + Ticket + |> where([t], t.is_paid == false and t.is_refunded == false) + |> Repo.all() + |> Repo.preload(:ticket_type) + end + + @doc """ + Gets a single ticket. + + Raises `Ecto.NoResultsError` if the Ticket does not exist. + + ## Examples + + iex> get_ticket!(123) + %Ticket{} + + iex> get_ticket!(456) + ** (Ecto.NoResultsError) + + """ + def get_ticket!(id), do: Repo.get!(Ticket, id) + + @doc """ + Creates a ticket. + + ## Examples + + iex> create_ticket(%{field: value}) + {:ok, %Ticket{}} + + iex> create_ticket(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_ticket(attrs \\ %{}) do + %Ticket{} + |> Ticket.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a ticket. + + ## Examples + + iex> update_ticket(ticket, %{field: new_value}) + {:ok, %Ticket{}} + + iex> update_ticket(ticket, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_ticket(%Ticket{} = ticket, attrs) do + ticket + |> Ticket.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a ticket. + + ## Examples + + iex> delete_ticket(ticket) + {:ok, %Ticket{}} + + iex> delete_ticket(ticket) + {:error, %Ecto.Changeset{}} + + """ + def delete_ticket(%Ticket{} = ticket) do + Repo.delete(ticket) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking ticket changes. + + ## Examples + + iex> change_ticket(ticket) + %Ecto.Changeset{data: %Ticket{}} + + """ + def change_ticket(%Ticket{} = ticket, attrs \\ %{}) do + Ticket.changeset(ticket, attrs) + end +end diff --git a/lib/elixir_conf_africa/tickets/ticket.ex b/lib/elixir_conf_africa/tickets/ticket.ex new file mode 100644 index 0000000..edc1dbe --- /dev/null +++ b/lib/elixir_conf_africa/tickets/ticket.ex @@ -0,0 +1,53 @@ +defmodule ElixirConfAfrica.Tickets.Ticket do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "tickets" do + field :name, :string + field :email, :string + field :ticketid, :string + field :quantity, :integer + field :cost, :integer + belongs_to :ticket_type, ElixirConfAfrica.TicketTypes.TicketType + field :is_paid, :boolean, default: false + field :is_refunded, :boolean, default: false + field :phone_number, :string + field :is_scanned, :boolean, default: false + field :email_sent, :boolean, default: true + + timestamps() + end + + @doc false + def changeset(ticket, attrs) do + ticket + |> cast(attrs, [ + :name, + :email, + :ticketid, + :quantity, + :ticket_type_id, + :cost, + :is_paid, + :is_scanned, + :is_refunded, + :phone_number, + :email_sent + ]) + |> validate_required([ + :name, + :email, + :ticketid, + :quantity, + :ticket_type_id, + :cost, + :is_paid, + :is_refunded, + :is_scanned, + :email_sent + ]) + |> unique_constraint(:ticketid) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + end +end diff --git a/lib/elixir_conf_africa_web.ex b/lib/elixir_conf_africa_web.ex index 5afb506..076758a 100644 --- a/lib/elixir_conf_africa_web.ex +++ b/lib/elixir_conf_africa_web.ex @@ -58,6 +58,15 @@ defmodule ElixirConfAfricaWeb do end end + def admin_live_view do + quote do + use Phoenix.LiveView, + layout: {ElixirConfAfricaWeb.Layouts, :admin} + + unquote(html_helpers()) + end + end + def live_component do quote do use Phoenix.LiveComponent diff --git a/lib/elixir_conf_africa_web/.DS_Store b/lib/elixir_conf_africa_web/.DS_Store new file mode 100644 index 0000000..806010c Binary files /dev/null and b/lib/elixir_conf_africa_web/.DS_Store differ diff --git a/lib/elixir_conf_africa_web/components/core_components.ex b/lib/elixir_conf_africa_web/components/core_components.ex index 7c0e312..cf43e38 100644 --- a/lib/elixir_conf_africa_web/components/core_components.ex +++ b/lib/elixir_conf_africa_web/components/core_components.ex @@ -225,9 +225,9 @@ defmodule ElixirConfAfricaWeb.CoreComponents do + + + + <%= @inner_content %> diff --git a/lib/elixir_conf_africa_web/components/layouts/root.html.heex b/lib/elixir_conf_africa_web/components/layouts/root.html.heex index d60757a..fa99b53 100644 --- a/lib/elixir_conf_africa_web/components/layouts/root.html.heex +++ b/lib/elixir_conf_africa_web/components/layouts/root.html.heex @@ -10,8 +10,55 @@ + + + <%= @inner_content %> diff --git a/lib/elixir_conf_africa_web/controllers/user_confirmation_controller.ex b/lib/elixir_conf_africa_web/controllers/user_confirmation_controller.ex new file mode 100644 index 0000000..de26636 --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_confirmation_controller.ex @@ -0,0 +1,56 @@ +defmodule ElixirConfAfricaWeb.UserConfirmationController do + use ElixirConfAfricaWeb, :controller + + alias ElixirConfAfrica.Accounts + + def new(conn, _params) do + render(conn, :new) + end + + def create(conn, %{"user" => %{"email" => email}}) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + end + + conn + |> put_flash( + :info, + "If your email is in our system and it has not been confirmed yet, " <> + "you will receive an email with instructions shortly." + ) + |> redirect(to: ~p"/") + end + + def edit(conn, %{"token" => token}) do + render(conn, :edit, token: token) + end + + # Do not log in the user after confirmation to avoid a + # leaked token giving the user access to the account. + def update(conn, %{"token" => token}) do + case Accounts.confirm_user(token) do + {:ok, _} -> + conn + |> put_flash(:info, "User confirmed successfully.") + |> redirect(to: ~p"/") + + :error -> + # If there is a current user and the account was already confirmed, + # then odds are that the confirmation link was already visited, either + # by some automation or by the user themselves, so we redirect without + # a warning message. + case conn.assigns do + %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + redirect(conn, to: ~p"/") + + %{} -> + conn + |> put_flash(:error, "User confirmation link is invalid or it has expired.") + |> redirect(to: ~p"/") + end + end + end +end diff --git a/lib/elixir_conf_africa_web/controllers/user_confirmation_html.ex b/lib/elixir_conf_africa_web/controllers/user_confirmation_html.ex new file mode 100644 index 0000000..2f95938 --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_confirmation_html.ex @@ -0,0 +1,5 @@ +defmodule ElixirConfAfricaWeb.UserConfirmationHTML do + use ElixirConfAfricaWeb, :html + + embed_templates "user_confirmation_html/*" +end diff --git a/lib/elixir_conf_africa_web/controllers/user_confirmation_html/edit.html.heex b/lib/elixir_conf_africa_web/controllers/user_confirmation_html/edit.html.heex new file mode 100644 index 0000000..f320cf7 --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_confirmation_html/edit.html.heex @@ -0,0 +1,14 @@ +
+ <.header class="text-center">Confirm account + + <.simple_form for={@conn.params["user"]} as={:user} action={~p"/users/confirm/#{@token}"}> + <:actions> + <.button class="w-full">Confirm my account + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
diff --git a/lib/elixir_conf_africa_web/controllers/user_confirmation_html/new.html.heex b/lib/elixir_conf_africa_web/controllers/user_confirmation_html/new.html.heex new file mode 100644 index 0000000..cb29e0b --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_confirmation_html/new.html.heex @@ -0,0 +1,20 @@ +
+ <.header class="text-center"> + No confirmation instructions received? + <:subtitle>We'll send a new confirmation link to your inbox + + + <.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/users/confirm"}> + <.input field={f[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Resend confirmation instructions + + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
diff --git a/lib/elixir_conf_africa_web/controllers/user_registration_controller.ex b/lib/elixir_conf_africa_web/controllers/user_registration_controller.ex new file mode 100644 index 0000000..aa6a09b --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_registration_controller.ex @@ -0,0 +1,30 @@ +defmodule ElixirConfAfricaWeb.UserRegistrationController do + use ElixirConfAfricaWeb, :controller + + alias ElixirConfAfrica.Accounts + alias ElixirConfAfrica.Accounts.User + alias ElixirConfAfricaWeb.UserAuth + + def new(conn, _params) do + changeset = Accounts.change_user_registration(%User{}) + render(conn, :new, changeset: changeset) + end + + def create(conn, %{"user" => user_params}) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + + conn + |> put_flash(:info, "User created successfully.") + |> UserAuth.log_in_user(user) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + end +end diff --git a/lib/elixir_conf_africa_web/controllers/user_registration_html.ex b/lib/elixir_conf_africa_web/controllers/user_registration_html.ex new file mode 100644 index 0000000..dc05280 --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_registration_html.ex @@ -0,0 +1,5 @@ +defmodule ElixirConfAfricaWeb.UserRegistrationHTML do + use ElixirConfAfricaWeb, :html + + embed_templates "user_registration_html/*" +end diff --git a/lib/elixir_conf_africa_web/controllers/user_registration_html/new.html.heex b/lib/elixir_conf_africa_web/controllers/user_registration_html/new.html.heex new file mode 100644 index 0000000..a06b058 --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_registration_html/new.html.heex @@ -0,0 +1,25 @@ +
+ <.header class="text-center"> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> + Sign in + + to your account now. + + + + <.simple_form :let={f} for={@changeset} action={~p"/users/register"}> + <.error :if={@changeset.action == :insert}> + Oops, something went wrong! Please check the errors below. + + + <.input field={f[:email]} type="email" label="Email" required /> + <.input field={f[:password]} type="password" label="Password" required /> + + <:actions> + <.button phx-disable-with="Creating account..." class="w-full">Create an account + + +
diff --git a/lib/elixir_conf_africa_web/controllers/user_reset_password_controller.ex b/lib/elixir_conf_africa_web/controllers/user_reset_password_controller.ex new file mode 100644 index 0000000..9b4da39 --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_reset_password_controller.ex @@ -0,0 +1,58 @@ +defmodule ElixirConfAfricaWeb.UserResetPasswordController do + use ElixirConfAfricaWeb, :controller + + alias ElixirConfAfrica.Accounts + + plug :get_user_by_reset_password_token when action in [:edit, :update] + + def new(conn, _params) do + render(conn, :new) + end + + def create(conn, %{"user" => %{"email" => email}}) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_reset_password_instructions( + user, + &url(~p"/users/reset_password/#{&1}") + ) + end + + conn + |> put_flash( + :info, + "If your email is in our system, you will receive instructions to reset your password shortly." + ) + |> redirect(to: ~p"/") + end + + def edit(conn, _params) do + render(conn, :edit, changeset: Accounts.change_user_password(conn.assigns.user)) + end + + # Do not log in the user after reset password to avoid a + # leaked token giving the user access to the account. + def update(conn, %{"user" => user_params}) do + case Accounts.reset_user_password(conn.assigns.user, user_params) do + {:ok, _} -> + conn + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: ~p"/users/log_in") + + {:error, changeset} -> + render(conn, :edit, changeset: changeset) + end + end + + defp get_user_by_reset_password_token(conn, _opts) do + %{"token" => token} = conn.params + + if user = Accounts.get_user_by_reset_password_token(token) do + conn |> assign(:user, user) |> assign(:token, token) + else + conn + |> put_flash(:error, "Reset password link is invalid or it has expired.") + |> redirect(to: ~p"/") + |> halt() + end + end +end diff --git a/lib/elixir_conf_africa_web/controllers/user_reset_password_html.ex b/lib/elixir_conf_africa_web/controllers/user_reset_password_html.ex new file mode 100644 index 0000000..cadaa71 --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_reset_password_html.ex @@ -0,0 +1,5 @@ +defmodule ElixirConfAfricaWeb.UserResetPasswordHTML do + use ElixirConfAfricaWeb, :html + + embed_templates "user_reset_password_html/*" +end diff --git a/lib/elixir_conf_africa_web/controllers/user_reset_password_html/edit.html.heex b/lib/elixir_conf_africa_web/controllers/user_reset_password_html/edit.html.heex new file mode 100644 index 0000000..b8be4ce --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_reset_password_html/edit.html.heex @@ -0,0 +1,29 @@ +
+ <.header class="text-center"> + Reset Password + + + <.simple_form :let={f} for={@changeset} action={~p"/users/reset_password/#{@token}"}> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + + <.input field={f[:password]} type="password" label="New Password" required /> + <.input + field={f[:password_confirmation]} + type="password" + label="Confirm new password" + required + /> + <:actions> + <.button phx-disable-with="Resetting..." class="w-full"> + Reset password + + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
diff --git a/lib/elixir_conf_africa_web/controllers/user_reset_password_html/new.html.heex b/lib/elixir_conf_africa_web/controllers/user_reset_password_html/new.html.heex new file mode 100644 index 0000000..cc36db4 --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_reset_password_html/new.html.heex @@ -0,0 +1,20 @@ +
+ <.header class="text-center"> + Forgot your password? + <:subtitle>We'll send a password reset link to your inbox + + + <.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/users/reset_password"}> + <.input field={f[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Send password reset instructions + + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
diff --git a/lib/elixir_conf_africa_web/controllers/user_session_controller.ex b/lib/elixir_conf_africa_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..3373d08 --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_session_controller.ex @@ -0,0 +1,29 @@ +defmodule ElixirConfAfricaWeb.UserSessionController do + use ElixirConfAfricaWeb, :controller + + alias ElixirConfAfrica.Accounts + alias ElixirConfAfricaWeb.UserAuth + + def new(conn, _params) do + render(conn, :new, error_message: nil) + end + + def create(conn, %{"user" => user_params}) do + %{"email" => email, "password" => password} = user_params + + if user = Accounts.get_user_by_email_and_password(email, password) do + conn + |> put_flash(:info, "Welcome back!") + |> UserAuth.log_in_user(user, user_params) + else + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. + render(conn, :new, error_message: "Invalid email or password") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/lib/elixir_conf_africa_web/controllers/user_session_html.ex b/lib/elixir_conf_africa_web/controllers/user_session_html.ex new file mode 100644 index 0000000..5391143 --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_session_html.ex @@ -0,0 +1,5 @@ +defmodule ElixirConfAfricaWeb.UserSessionHTML do + use ElixirConfAfricaWeb, :html + + embed_templates "user_session_html/*" +end diff --git a/lib/elixir_conf_africa_web/controllers/user_session_html/new.html.heex b/lib/elixir_conf_africa_web/controllers/user_session_html/new.html.heex new file mode 100644 index 0000000..28c2467 --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_session_html/new.html.heex @@ -0,0 +1,31 @@ +
+ <.header class="text-center"> + Sign in to account + <:subtitle> + Don't have an account? + <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> + Sign up + + for an account now. + + + + <.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/users/log_in"}> + <.error :if={@error_message}><%= @error_message %> + + <.input field={f[:email]} type="email" label="Email" required /> + <.input field={f[:password]} type="password" label="Password" required /> + + <:actions :let={f}> + <.input field={f[:remember_me]} type="checkbox" label="Keep me logged in" /> + <.link href={~p"/users/reset_password"} class="text-sm font-semibold"> + Forgot your password? + + + <:actions> + <.button phx-disable-with="Signing in..." class="w-full"> + Sign in + + + +
diff --git a/lib/elixir_conf_africa_web/controllers/user_settings_controller.ex b/lib/elixir_conf_africa_web/controllers/user_settings_controller.ex new file mode 100644 index 0000000..9c7d79d --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_settings_controller.ex @@ -0,0 +1,74 @@ +defmodule ElixirConfAfricaWeb.UserSettingsController do + use ElixirConfAfricaWeb, :controller + + alias ElixirConfAfrica.Accounts + alias ElixirConfAfricaWeb.UserAuth + + plug :assign_email_and_password_changesets + + def edit(conn, _params) do + render(conn, :edit) + end + + def update(conn, %{"action" => "update_email"} = params) do + %{"current_password" => password, "user" => user_params} = params + user = conn.assigns.current_user + + case Accounts.apply_user_email(user, password, user_params) do + {:ok, applied_user} -> + Accounts.deliver_update_email_instructions( + applied_user, + user.email, + &url(~p"/users/settings/confirm_email/#{&1}") + ) + + conn + |> put_flash( + :info, + "A link to confirm your email change has been sent to the new address." + ) + |> redirect(to: ~p"/users/settings") + + {:error, changeset} -> + render(conn, :edit, email_changeset: changeset) + end + end + + def update(conn, %{"action" => "update_password"} = params) do + %{"current_password" => password, "user" => user_params} = params + user = conn.assigns.current_user + + case Accounts.update_user_password(user, password, user_params) do + {:ok, user} -> + conn + |> put_flash(:info, "Password updated successfully.") + |> put_session(:user_return_to, ~p"/users/settings") + |> UserAuth.log_in_user(user) + + {:error, changeset} -> + render(conn, :edit, password_changeset: changeset) + end + end + + def confirm_email(conn, %{"token" => token}) do + case Accounts.update_user_email(conn.assigns.current_user, token) do + :ok -> + conn + |> put_flash(:info, "Email changed successfully.") + |> redirect(to: ~p"/users/settings") + + :error -> + conn + |> put_flash(:error, "Email change link is invalid or it has expired.") + |> redirect(to: ~p"/users/settings") + end + end + + defp assign_email_and_password_changesets(conn, _opts) do + user = conn.assigns.current_user + + conn + |> assign(:email_changeset, Accounts.change_user_email(user)) + |> assign(:password_changeset, Accounts.change_user_password(user)) + end +end diff --git a/lib/elixir_conf_africa_web/controllers/user_settings_html.ex b/lib/elixir_conf_africa_web/controllers/user_settings_html.ex new file mode 100644 index 0000000..93a42fd --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_settings_html.ex @@ -0,0 +1,5 @@ +defmodule ElixirConfAfricaWeb.UserSettingsHTML do + use ElixirConfAfricaWeb, :html + + embed_templates "user_settings_html/*" +end diff --git a/lib/elixir_conf_africa_web/controllers/user_settings_html/edit.html.heex b/lib/elixir_conf_africa_web/controllers/user_settings_html/edit.html.heex new file mode 100644 index 0000000..915c3fd --- /dev/null +++ b/lib/elixir_conf_africa_web/controllers/user_settings_html/edit.html.heex @@ -0,0 +1,63 @@ +<.header class="text-center"> + Account Settings + <:subtitle>Manage your account email address and password settings + + +
+
+ <.simple_form :let={f} for={@email_changeset} action={~p"/users/settings"} id="update_email"> + <.error :if={@email_changeset.action}> + Oops, something went wrong! Please check the errors below. + + + <.input field={f[:action]} type="hidden" name="action" value="update_email" /> + + <.input field={f[:email]} type="email" label="Email" required /> + <.input + field={f[:current_password]} + name="current_password" + type="password" + label="Current Password" + required + id="current_password_for_email" + /> + <:actions> + <.button phx-disable-with="Changing...">Change Email + + +
+
+ <.simple_form + :let={f} + for={@password_changeset} + action={~p"/users/settings"} + id="update_password" + > + <.error :if={@password_changeset.action}> + Oops, something went wrong! Please check the errors below. + + + <.input field={f[:action]} type="hidden" name="action" value="update_password" /> + + <.input field={f[:password]} type="password" label="New password" required /> + <.input + field={f[:password_confirmation]} + type="password" + label="Confirm new password" + required + /> + + <.input + field={f[:current_password]} + name="current_password" + type="password" + label="Current password" + id="current_password_for_password" + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Password + + +
+
diff --git a/lib/elixir_conf_africa_web/live/.DS_Store b/lib/elixir_conf_africa_web/live/.DS_Store new file mode 100644 index 0000000..efd35b9 Binary files /dev/null and b/lib/elixir_conf_africa_web/live/.DS_Store differ diff --git a/lib/elixir_conf_africa_web/live/event_live/form_component.ex b/lib/elixir_conf_africa_web/live/event_live/form_component.ex deleted file mode 100644 index 504822e..0000000 --- a/lib/elixir_conf_africa_web/live/event_live/form_component.ex +++ /dev/null @@ -1,95 +0,0 @@ -defmodule ElixirConfAfricaWeb.EventLive.FormComponent do - use ElixirConfAfricaWeb, :live_component - - alias ElixirConfAfrica.Events - - @impl true - def render(assigns) do - ~H""" -
- <.header> - <%= @title %> - <:subtitle>Use this form to manage event records in your database. - - - <.simple_form - for={@form} - id="event-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > - <.input field={@form[:name]} type="text" label="Name" /> - <.input field={@form[:event_type]} type="text" label="Event type" /> - <.input field={@form[:location]} type="text" label="Location" /> - <.input field={@form[:description]} type="text" label="Description" /> - <.input field={@form[:start_date]} type="datetime-local" label="Start date" /> - <.input field={@form[:end_date]} type="datetime-local" label="End date" /> - <:actions> - <.button phx-disable-with="Saving...">Save Event - - -
- """ - end - - @impl true - def update(%{event: event} = assigns, socket) do - changeset = Events.change_event(event) - - {:ok, - socket - |> assign(assigns) - |> assign_form(changeset)} - end - - @impl true - def handle_event("validate", %{"event" => event_params}, socket) do - changeset = - socket.assigns.event - |> Events.change_event(event_params) - |> Map.put(:action, :validate) - - {:noreply, assign_form(socket, changeset)} - end - - def handle_event("save", %{"event" => event_params}, socket) do - save_event(socket, socket.assigns.action, event_params) - end - - defp save_event(socket, :edit, event_params) do - case Events.update_event(socket.assigns.event, event_params) do - {:ok, event} -> - notify_parent({:saved, event}) - - {:noreply, - socket - |> put_flash(:info, "Event updated successfully") - |> push_patch(to: socket.assigns.patch)} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} - end - end - - defp save_event(socket, :new, event_params) do - case Events.create_event(event_params) do - {:ok, event} -> - notify_parent({:saved, event}) - - {:noreply, - socket - |> put_flash(:info, "Event created successfully") - |> push_patch(to: socket.assigns.patch)} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} - end - end - - defp assign_form(socket, %Ecto.Changeset{} = changeset) do - assign(socket, :form, to_form(changeset)) - end - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) -end diff --git a/lib/elixir_conf_africa_web/live/event_live/index.ex b/lib/elixir_conf_africa_web/live/event_live/index.ex index 91803fc..a9667da 100644 --- a/lib/elixir_conf_africa_web/live/event_live/index.ex +++ b/lib/elixir_conf_africa_web/live/event_live/index.ex @@ -1,12 +1,12 @@ defmodule ElixirConfAfricaWeb.EventLive.Index do use ElixirConfAfricaWeb, :live_view - alias ElixirConfAfrica.Events - alias ElixirConfAfrica.Events.Event - + alias ElixirConfAfrica.Tickets.Ticket + alias ElixirConfAfrica.TicketTypes @impl true def mount(_params, _session, socket) do - {:ok, stream(socket, :events, Events.list_events())} + ticket_types = TicketTypes.list_ticket_types_with_remaining_tickets() + {:ok, assign(socket, :ticket_types, ticket_types)} end @impl true @@ -14,34 +14,17 @@ defmodule ElixirConfAfricaWeb.EventLive.Index do {:noreply, apply_action(socket, socket.assigns.live_action, params)} end - defp apply_action(socket, :edit, %{"id" => id}) do - socket - |> assign(:page_title, "Edit Event") - |> assign(:event, Events.get_event!(id)) - end - - defp apply_action(socket, :new, _params) do - socket - |> assign(:page_title, "New Event") - |> assign(:event, %Event{}) - end - defp apply_action(socket, :index, _params) do socket - |> assign(:page_title, "Listing Events") - |> assign(:event, nil) - end - - @impl true - def handle_info({ElixirConfAfricaWeb.EventLive.FormComponent, {:saved, event}}, socket) do - {:noreply, stream_insert(socket, :events, event)} + |> assign(:page_title, "ElixirConf Africa 2024") end - @impl true - def handle_event("delete", %{"id" => id}, socket) do - event = Events.get_event!(id) - {:ok, _} = Events.delete_event(event) + defp apply_action(socket, :ticket, params) do + ticket_type = TicketTypes.get_ticket_type!(params["ticket_type_id"]) - {:noreply, stream_delete(socket, :events, event)} + socket + |> assign(:page_title, "#{ticket_type.name} Ticket") + |> assign(:ticket_type, ticket_type) + |> assign(:ticket, %Ticket{}) end end diff --git a/lib/elixir_conf_africa_web/live/event_live/index.html.heex b/lib/elixir_conf_africa_web/live/event_live/index.html.heex index 4b14fe0..10fd788 100644 --- a/lib/elixir_conf_africa_web/live/event_live/index.html.heex +++ b/lib/elixir_conf_africa_web/live/event_live/index.html.heex @@ -1,51 +1,63 @@ -<.header> - Listing Events - <:actions> - <.link patch={~p"/events/new"}> - <.button>New Event - - - - -<.table - id="events" - rows={@streams.events} - row_click={fn {_id, event} -> JS.navigate(~p"/events/#{event}") end} -> - <:col :let={{_id, event}} label="Name"><%= event.name %> - <:col :let={{_id, event}} label="Event type"><%= event.event_type %> - <:col :let={{_id, event}} label="Location"><%= event.location %> - <:col :let={{_id, event}} label="Description"><%= event.description %> - <:col :let={{_id, event}} label="Start date"><%= event.start_date %> - <:col :let={{_id, event}} label="End date"><%= event.end_date %> - <:action :let={{_id, event}}> -
- <.link navigate={~p"/events/#{event}"}>Show -
- <.link patch={~p"/events/#{event}/edit"}>Edit - - <:action :let={{id, event}}> - <.link - phx-click={JS.push("delete", value: %{id: event.id}) |> hide("##{id}")} - data-confirm="Are you sure?" - > - Delete - - - - <.modal - :if={@live_action in [:new, :edit]} - id="event-modal" + :if={@live_action in [:ticket]} + id="buy-ticket-modal" show - on_cancel={JS.patch(~p"/events")} + on_cancel={JS.patch(~p"/event")} > <.live_component - module={ElixirConfAfricaWeb.EventLive.FormComponent} - id={@event.id || :new} + module={ElixirConfAfricaWeb.TicketLive.FormComponent} + id={:new} title={@page_title} action={@live_action} - event={@event} - patch={~p"/events"} + ticket_type={@ticket_type} + ticket={@ticket} + patch={~p"/event"} /> + +
+ ElixirConf Africa 2024 +
+ +
+
+
+
+

Available Tickets

+ +
+ <%= for ticket_type <- @ticket_types do %> +
+
+

<%= ticket_type.name %>

+

+ <%= ticket_type.remaining_tickets %> Tickets Remaining +

+
+

+ <%= ticket_type.description %> +

+
+

KSH <%= ticket_type.price %>

+
+ <.link + patch={~p"/event/#{ticket_type.id}/buy"} + id={"buy-#{ticket_type.id}-tickets"} + > + + +
+
+
+ <% end %> +
+
+
+
+
diff --git a/lib/elixir_conf_africa_web/live/event_live/show.ex b/lib/elixir_conf_africa_web/live/event_live/show.ex deleted file mode 100644 index 1372982..0000000 --- a/lib/elixir_conf_africa_web/live/event_live/show.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule ElixirConfAfricaWeb.EventLive.Show do - use ElixirConfAfricaWeb, :live_view - - alias ElixirConfAfrica.Events - alias ElixirConfAfrica.Repo - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _, socket) do - event = - id - |> Events.get_event!() - |> Repo.preload(:ticket_types) - - {:noreply, - socket - |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:event, event)} - end - - defp page_title(:show), do: "Show Event" - defp page_title(:edit), do: "Edit Event" -end diff --git a/lib/elixir_conf_africa_web/live/event_live/show.html.heex b/lib/elixir_conf_africa_web/live/event_live/show.html.heex deleted file mode 100644 index 2be9944..0000000 --- a/lib/elixir_conf_africa_web/live/event_live/show.html.heex +++ /dev/null @@ -1,58 +0,0 @@ -<.header> - Event <%= @event.id %> - <:subtitle>This is a event record from your database. - <:actions> - <.link patch={~p"/events/#{@event}/show/edit"} phx-click={JS.push_focus()}> - <.button>Edit event - - - - -<.list> - <:item title="Name"><%= @event.name %> - <:item title="Event type"><%= @event.event_type %> - <:item title="Location"><%= @event.location %> - <:item title="Description"><%= @event.description %> - <:item title="Start date"><%= @event.start_date %> - <:item title="End date"><%= @event.end_date %> - -
-<.header>Event Ticket Types - -<.table id="ticket_types" rows={@event.ticket_types}> - <:col :let={ticket_type} label="Name"><%= ticket_type.name %> - <:col :let={ticket_type} label="Description"><%= ticket_type.description %> - <:col :let={ticket_type} label="Price"><%= ticket_type.price %> - <:action :let={ticket_type}> -
- <.link navigate={~p"/ticket_types/#{ticket_type}"}>Show -
- <.link navigate={~p"/ticket_types/#{ticket_type}/edit"}>Edit - - <:action :let={ticket_type}> - <.link - phx-click={JS.push("delete", value: %{id: ticket_type.id}) |> hide("##{ticket_type.id}")} - data-confirm="Are you sure?" - > - Delete - - - - -<.back navigate={~p"/events"}>Back to events - -<.modal - :if={@live_action == :edit} - id="event-modal" - show - on_cancel={JS.patch(~p"/events/#{@event}")} -> - <.live_component - module={ElixirConfAfricaWeb.EventLive.FormComponent} - id={@event.id} - title={@page_title} - action={@live_action} - event={@event} - patch={~p"/events/#{@event}"} - /> - diff --git a/lib/elixir_conf_africa_web/live/home_live/index.ex b/lib/elixir_conf_africa_web/live/home_live/index.ex index 1291b04..fc61708 100644 --- a/lib/elixir_conf_africa_web/live/home_live/index.ex +++ b/lib/elixir_conf_africa_web/live/home_live/index.ex @@ -1,24 +1,17 @@ defmodule ElixirConfAfricaWeb.HomeLive.Index do use ElixirConfAfricaWeb, :live_view - alias ElixirConfAfrica.Events - + @impl true def mount(_params, _session, socket) do - # these value are more static and we should find away of display this data to home page - event_name = "ElixirConf Africa #{get_current_year()}" - - event = - Events.get_event_with_ticket_types_by_event_name(event_name) - - available_ticket = Events.get_total_number_of_available_tickets(event_name) + {:ok, socket} + end - {:ok, - socket - |> assign(:event, event)} - |> assign(available_ticket: available_ticket) + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} end - defp get_current_year do - %{year: year} = DateTime.utc_now() - year + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "ElixirConf Africa") end end diff --git a/lib/elixir_conf_africa_web/live/home_live/index.html.heex b/lib/elixir_conf_africa_web/live/home_live/index.html.heex index cec095a..081d907 100644 --- a/lib/elixir_conf_africa_web/live/home_live/index.html.heex +++ b/lib/elixir_conf_africa_web/live/home_live/index.html.heex @@ -1,143 +1,25 @@ -
-
- -
- -
-

Logout

- -
-
-
-
- ElixirConf Africa 2024 -
-
- -
-
- -
- Backend Engineer at Wezacare Solutions -
- -
-
- -

Attending

-
-
- -

June 19 2024 | June 23 2024

-
-
-
-

- Details -

-

- Cart -

-

- Payment +

+ Elixir Conf 2024

-

- Payment Details +

+ Dive deep into Elixir through interactive workshops led by experienced mentors, providing practical experience and enhancing your understanding of the language.

-
-
- -<%!-- navbar --%> - -
-
-
-
-

- Event Information -

-
-
-

Start Date

-

<%= @event.start_date %>

-
-
-

End Date

-

<%= @event.start_date %>

-
-
-

Event Type

-

<%= @event.event_type %>

-
-
-

Available Tickets

-

<%= @available_tickets %>

-
-
-
-

Location

-

<%= @event.location %>

-
-
-

Hosted by

-

Elixir Conf Africa

-
-
-
-

Available Tickets

- -
- <%= for ticket_type <- @event.ticket_types do %> -
-
-

<%= ticket_type.name %>

-

<%= ticket_type.number %>Tickets

-
-

- <%= ticket_type.description %> -

-
-

KSH <%= ticket_type.price %>

-
- - -
-
-
- <% end %> -
-
+

+

+ <.link navigate={~p"/event"}> + +
diff --git a/lib/elixir_conf_africa_web/live/paid_ticket_live/index.ex b/lib/elixir_conf_africa_web/live/paid_ticket_live/index.ex new file mode 100644 index 0000000..ca7f5bd --- /dev/null +++ b/lib/elixir_conf_africa_web/live/paid_ticket_live/index.ex @@ -0,0 +1,36 @@ +defmodule ElixirConfAfricaWeb.PaidTicketLive.Index do + use ElixirConfAfricaWeb, :admin_live_view + + alias ElixirConfAfrica.Emails + alias ElixirConfAfrica.Tickets + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :ticket_collection, list_paid_tickets())} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Paid Tickets") + end + + @impl true + + def handle_event("send_email", %{"ticketid" => ticketid}, socket) do + ticket = Tickets.get_ticket_by_ticketid!(ticketid) + Emails.deliver_ticket_by_email(ticket.email, "localhost:4900/tickets/#{ticket.ticketid}") + + {:noreply, + socket + |> put_flash(:info, "Ticket sent successfully")} + end + + defp list_paid_tickets do + Tickets.list_paid_tickets() + end +end diff --git a/lib/elixir_conf_africa_web/live/paid_ticket_live/index.html.heex b/lib/elixir_conf_africa_web/live/paid_ticket_live/index.html.heex new file mode 100644 index 0000000..04ec952 --- /dev/null +++ b/lib/elixir_conf_africa_web/live/paid_ticket_live/index.html.heex @@ -0,0 +1,41 @@ +
+

Listing Paid Tickets

+
+ + + + + + + + + + + + + + + <%= for ticket <- @ticket_collection do %> + + + + + + + + + + <% end %> + +
NameEmailTicket idQuantityAmountTypeEmail Action
<%= ticket.name %><%= ticket.email %><%= ticket.ticketid %><%= ticket.quantity %><%= ticket.cost %> KSH /=<%= ticket.ticket_type.name %> +
+ +
+
diff --git a/lib/elixir_conf_africa_web/live/refunded_ticket_live/index.ex b/lib/elixir_conf_africa_web/live/refunded_ticket_live/index.ex new file mode 100644 index 0000000..6c639e7 --- /dev/null +++ b/lib/elixir_conf_africa_web/live/refunded_ticket_live/index.ex @@ -0,0 +1,24 @@ +defmodule ElixirConfAfricaWeb.RefundedTicketLive.Index do + use ElixirConfAfricaWeb, :admin_live_view + + alias ElixirConfAfrica.Tickets + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :ticket_collection, list_refunded_tickets())} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Refunded Tickets") + end + + defp list_refunded_tickets do + Tickets.list_refunded_tickets() + end +end diff --git a/lib/elixir_conf_africa_web/live/refunded_ticket_live/index.html.heex b/lib/elixir_conf_africa_web/live/refunded_ticket_live/index.html.heex new file mode 100644 index 0000000..199f01d --- /dev/null +++ b/lib/elixir_conf_africa_web/live/refunded_ticket_live/index.html.heex @@ -0,0 +1,33 @@ +
+

Listing Refunded Tickets

+
+ + + + + + + + + + + + + + <%= for ticket <- @ticket_collection do %> + + + + + + + + + <% end %> + +
NameEmailTicket idQuantityAmountType
<%= ticket.name %><%= ticket.email %><%= ticket.ticketid %><%= ticket.quantity %><%= ticket.cost %> KSH /=<%= ticket.ticket_type.name %>
diff --git a/lib/elixir_conf_africa_web/live/success_live/index.ex b/lib/elixir_conf_africa_web/live/success_live/index.ex new file mode 100644 index 0000000..791d7da --- /dev/null +++ b/lib/elixir_conf_africa_web/live/success_live/index.ex @@ -0,0 +1,75 @@ +defmodule ElixirConfAfricaWeb.SuccessLive.Index do + use ElixirConfAfricaWeb, :live_view + + alias ElixirConfAfrica.Tickets + + alias ElixirConfAfrica.Emails + alias ElixirConfAfrica.Paystack + + def mount(params, _session, socket) do + Paystack.test_verification(params["trxref"]) + + case Paystack.test_verification(params["trxref"]) do + # handle mpesa payments + %{"status" => "success", "authorization" => %{"mobile_money_number" => phone_number}} -> + ticket = Tickets.get_ticket_by_ticketid!(params["trxref"]) + + case ticket.email_sent do + true -> + deliver_ticket_by_email(ticket.email, ticket.ticketid) + + {:ok, _ticket} = + Tickets.update_ticket(ticket, %{is_paid: true, phone_number: phone_number}) + + false -> + {:ok, _ticket} = + Tickets.update_ticket(ticket, %{ + is_paid: true, + email_sent: true, + phone_number: phone_number + }) + end + + {:ok, + socket + |> assign(:success, true) + |> assign(:ticket, ticket)} + + # handle card payments + %{"status" => "success"} -> + ticket = Tickets.get_ticket_by_ticketid!(params["trxref"]) + + case ticket.email_sent do + true -> + deliver_ticket_by_email(ticket.email, ticket.ticketid) + {:ok, _ticket} = Tickets.update_ticket(ticket, %{is_paid: true}) + + false -> + {:ok, _ticket} = Tickets.update_ticket(ticket, %{is_paid: true, email_sent: true}) + end + + {:ok, + socket + |> assign(:success, true) + |> assign(:ticket, ticket)} + + %{"status" => "failed"} -> + {:ok, + socket + |> assign(:success, false)} + + %{"status" => "abandoned"} -> + {:ok, + socket + |> assign(:success, false)} + end + end + + def deliver_ticket_by_email(email, ticketid) do + {:ok, _} = + Emails.deliver_ticket_by_email( + email, + "http://5.189.162.107:3200/tickets/#{ticketid}" + ) + end +end diff --git a/lib/elixir_conf_africa_web/live/success_live/index.html.heex b/lib/elixir_conf_africa_web/live/success_live/index.html.heex new file mode 100644 index 0000000..a9fbddf --- /dev/null +++ b/lib/elixir_conf_africa_web/live/success_live/index.html.heex @@ -0,0 +1,34 @@ +
+ <%= if @success == true do %> +
+ +

+ Payment Successful +

+

+ Thank you for your purchase , we have sent you your ticket via email , you can also view it here +

+ <.link navigate={~p"/tickets/#{@ticket.ticketid}"}> + + +
+ <% else %> +
+ +

+ Payment Unsuccessful +

+

+ Payment was unsuccessful , go back to the tickets and purchase +

+ <.link navigate={~p"/event"}> + + +
+ <% end %> +
diff --git a/lib/elixir_conf_africa_web/live/ticket_live/form_component.ex b/lib/elixir_conf_africa_web/live/ticket_live/form_component.ex new file mode 100644 index 0000000..c95c94c --- /dev/null +++ b/lib/elixir_conf_africa_web/live/ticket_live/form_component.ex @@ -0,0 +1,82 @@ +defmodule ElixirConfAfricaWeb.TicketLive.FormComponent do + use ElixirConfAfricaWeb, :live_component + alias ElixirConfAfrica.Paystack + alias ElixirConfAfrica.Tickets + + @impl true + def update(%{ticket: ticket} = assigns, socket) do + changeset = Tickets.change_ticket(ticket) + + {:ok, + socket + |> assign(assigns) + |> assign_form(changeset)} + end + + @impl true + def handle_event("validate", %{"ticket" => ticket_params}, socket) do + changeset = + socket.assigns.ticket + |> Tickets.change_ticket(ticket_params) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"ticket" => ticket_params}, socket) do + save_ticket(socket, socket.assigns.action, ticket_params) + end + + defp save_ticket(socket, :edit, ticket_params) do + case Tickets.update_ticket(socket.assigns.ticket, ticket_params) do + {:ok, _ticket} -> + {:noreply, + socket + |> put_flash(:info, "Ticket updated successfully") + |> push_redirect(to: socket.assigns.return_to)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :changeset, changeset)} + end + end + + defp save_ticket(socket, :new, ticket_params) do + case Tickets.create_ticket(ticket_params) do + {:ok, _ticket} -> + {:noreply, + socket + |> put_flash(:info, "Ticket created successfully") + |> push_redirect(to: socket.assigns.return_to)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end + + defp save_ticket(socket, :ticket, ticket_params) do + cost = socket.assigns.ticket_type.price * String.to_integer(ticket_params["quantity"]) + + paystack_initailization = + Paystack.initialize(ticket_params["email"], cost) + + new_ticket_params = + ticket_params + |> Map.put("ticket_type_id", socket.assigns.ticket_type.id) + |> Map.put("ticketid", paystack_initailization["reference"]) + |> Map.put("cost", cost) + + case Tickets.create_ticket(new_ticket_params) do + {:ok, _ticket} -> + {:noreply, + socket + |> redirect(external: paystack_initailization["authorization_url"])} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, :form, to_form(changeset)) + end +end diff --git a/lib/elixir_conf_africa_web/live/ticket_live/form_component.html.heex b/lib/elixir_conf_africa_web/live/ticket_live/form_component.html.heex new file mode 100644 index 0000000..40b6905 --- /dev/null +++ b/lib/elixir_conf_africa_web/live/ticket_live/form_component.html.heex @@ -0,0 +1,40 @@ +
+ <.simple_form + for={@form} + id="ticket-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > +

+ <%= @ticket_type.name %> Ticket - <%= @ticket_type.price %> KSH /= +

+ +

+ Your ticket and payment details will be sent to your email address you will input below. +

+ +
+
+ <.input field={@form[:name]} type="text" label="Name" /> +
+
+
+ <.input field={@form[:email]} type="text" label="Email" /> +
+
+ <.input field={@form[:quantity]} type="number" label="Quantity of Tickets" /> +
+ + <:actions> +
+ <.button + phx-disable-with="Proceeding.." + class="w-[100%] flex justify-center items-center bg-[#AD3989]" + > + Proceed to Payment + +
+ + +
diff --git a/lib/elixir_conf_africa_web/live/ticket_live/show.ex b/lib/elixir_conf_africa_web/live/ticket_live/show.ex new file mode 100644 index 0000000..edbae33 --- /dev/null +++ b/lib/elixir_conf_africa_web/live/ticket_live/show.ex @@ -0,0 +1,21 @@ +defmodule ElixirConfAfricaWeb.TicketLive.Show do + use ElixirConfAfricaWeb, :live_view + + alias ElixirConfAfrica.Tickets + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"ticketid" => ticketid}, _, socket) do + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:ticket, Tickets.get_ticket_by_ticketid!(ticketid))} + end + + defp page_title(:show), do: "Show Ticket" + defp page_title(:edit), do: "Edit Ticket" +end diff --git a/lib/elixir_conf_africa_web/live/ticket_live/show.html.heex b/lib/elixir_conf_africa_web/live/ticket_live/show.html.heex new file mode 100644 index 0000000..e553953 --- /dev/null +++ b/lib/elixir_conf_africa_web/live/ticket_live/show.html.heex @@ -0,0 +1,57 @@ +
+ ElixirConf Africa 2024 +
+ +
+
+

+ +

+
+
+ +

+ Elixir Safari +

+
+
+

+ Elixir Safari +

+
+
+
+

Name:

+

<%= @ticket.name %>

+
+
+ <%= @ticket.email %> +
+
+
+

20th June 2024

+

+

20th June 2024

+
+
+
+

+ Ticket Value +

+

+ <%= @ticket.ticket_type.name %> +

+
+
+
+
+
+ +
+
+
+
+
diff --git a/lib/elixir_conf_africa_web/live/ticket_type_live/form_component.ex b/lib/elixir_conf_africa_web/live/ticket_type_live/form_component.ex index b4219ce..e5e2354 100644 --- a/lib/elixir_conf_africa_web/live/ticket_type_live/form_component.ex +++ b/lib/elixir_conf_africa_web/live/ticket_type_live/form_component.ex @@ -3,35 +3,6 @@ defmodule ElixirConfAfricaWeb.TicketTypeLive.FormComponent do alias ElixirConfAfrica.TicketTypes - @impl true - def render(assigns) do - ~H""" -
- <.header> - <%= @title %> - <:subtitle>Use this form to manage ticket_type records in your database. - - - <.simple_form - for={@form} - id="ticket_type-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > - <.input field={@form[:event_id]} type="number" label="Event ID" /> - <.input field={@form[:name]} type="text" label="Name" /> - <.input field={@form[:number]} type="number" label="Number" /> - <.input field={@form[:description]} type="text" label="Description" /> - <.input field={@form[:price]} type="number" label="Price" step="any" /> - <:actions> - <.button phx-disable-with="Saving...">Save Ticket type - - -
- """ - end - @impl true def update(%{ticket_type: ticket_type} = assigns, socket) do changeset = TicketTypes.change_ticket_type(ticket_type) @@ -58,37 +29,31 @@ defmodule ElixirConfAfricaWeb.TicketTypeLive.FormComponent do defp save_ticket_type(socket, :edit, ticket_type_params) do case TicketTypes.update_ticket_type(socket.assigns.ticket_type, ticket_type_params) do - {:ok, ticket_type} -> - notify_parent({:saved, ticket_type}) - + {:ok, _ticket_type} -> {:noreply, socket |> put_flash(:info, "Ticket type updated successfully") - |> push_patch(to: socket.assigns.patch)} + |> push_redirect(to: socket.assigns.patch)} {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} + {:noreply, assign(socket, :changeset, changeset)} end end defp save_ticket_type(socket, :new, ticket_type_params) do case TicketTypes.create_ticket_type(ticket_type_params) do - {:ok, ticket_type} -> - notify_parent({:saved, ticket_type}) - + {:ok, _ticket_type} -> {:noreply, socket |> put_flash(:info, "Ticket type created successfully") - |> push_patch(to: socket.assigns.patch)} + |> push_redirect(to: socket.assigns.patch)} {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} + {:noreply, assign(socket, changeset: changeset)} end end defp assign_form(socket, %Ecto.Changeset{} = changeset) do assign(socket, :form, to_form(changeset)) end - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) end diff --git a/lib/elixir_conf_africa_web/live/ticket_type_live/form_component.html.heex b/lib/elixir_conf_africa_web/live/ticket_type_live/form_component.html.heex new file mode 100644 index 0000000..339796a --- /dev/null +++ b/lib/elixir_conf_africa_web/live/ticket_type_live/form_component.html.heex @@ -0,0 +1,17 @@ +
+ <.simple_form + for={@form} + id="ticket_type-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input field={@form[:name]} type="text" label="Name" /> + <.input field={@form[:number]} type="number" label="Number" /> + <.input field={@form[:description]} type="text" label="Description" /> + <.input field={@form[:price]} type="number" label="Price" step="any" /> + <:actions> + <.button phx-disable-with="Saving...">Save Ticket type + + +
diff --git a/lib/elixir_conf_africa_web/live/ticket_type_live/index.ex b/lib/elixir_conf_africa_web/live/ticket_type_live/index.ex index 259c2be..6285f3b 100644 --- a/lib/elixir_conf_africa_web/live/ticket_type_live/index.ex +++ b/lib/elixir_conf_africa_web/live/ticket_type_live/index.ex @@ -1,12 +1,12 @@ defmodule ElixirConfAfricaWeb.TicketTypeLive.Index do - use ElixirConfAfricaWeb, :live_view + use ElixirConfAfricaWeb, :admin_live_view alias ElixirConfAfrica.TicketTypes alias ElixirConfAfrica.TicketTypes.TicketType @impl true def mount(_params, _session, socket) do - {:ok, stream(socket, :ticket_types, TicketTypes.list_ticket_types())} + {:ok, assign(socket, :ticket_types, list_ticket_types())} end @impl true @@ -32,19 +32,15 @@ defmodule ElixirConfAfricaWeb.TicketTypeLive.Index do |> assign(:ticket_type, nil) end - @impl true - def handle_info( - {ElixirConfAfricaWeb.TicketTypeLive.FormComponent, {:saved, ticket_type}}, - socket - ) do - {:noreply, stream_insert(socket, :ticket_types, ticket_type)} - end - @impl true def handle_event("delete", %{"id" => id}, socket) do ticket_type = TicketTypes.get_ticket_type!(id) {:ok, _} = TicketTypes.delete_ticket_type(ticket_type) - {:noreply, stream_delete(socket, :ticket_types, ticket_type)} + {:noreply, assign(socket, :ticket_types, list_ticket_types())} + end + + defp list_ticket_types do + TicketTypes.list_ticket_types() end end diff --git a/lib/elixir_conf_africa_web/live/ticket_type_live/index.html.heex b/lib/elixir_conf_africa_web/live/ticket_type_live/index.html.heex index ced7f9b..3d2653a 100644 --- a/lib/elixir_conf_africa_web/live/ticket_type_live/index.html.heex +++ b/lib/elixir_conf_africa_web/live/ticket_type_live/index.html.heex @@ -1,39 +1,13 @@ -<.header> - Listing Ticket types - <:actions> - <.link patch={~p"/ticket_types/new"}> - <.button>New Ticket type - - - - -<.table - id="ticket_types" - rows={@streams.ticket_types} - row_click={fn {_id, ticket_type} -> JS.navigate(~p"/ticket_types/#{ticket_type}") end} -> - <:col :let={{_id, ticket_type}} label="Name"><%= ticket_type.name %> - <:col :let={{_id, ticket_type}} label="Number"><%= ticket_type.number %> - <:col :let={{_id, ticket_type}} label="Event ID"> - <.link navigate={~p"/events/#{ticket_type.event_id}"}><%= ticket_type.event_id %> - - <:col :let={{_id, ticket_type}} label="Description"><%= ticket_type.description %> - <:col :let={{_id, ticket_type}} label="Price"><%= ticket_type.price %> - <:action :let={{_id, ticket_type}}> -
- <.link navigate={~p"/ticket_types/#{ticket_type}"}>Show -
- <.link patch={~p"/ticket_types/#{ticket_type}/edit"}>Edit - - <:action :let={{id, ticket_type}}> - <.link - phx-click={JS.push("delete", value: %{id: ticket_type.id}) |> hide("##{id}")} - data-confirm="Are you sure?" - > - Delete - - - +
+ <.header> + Listing Ticket types + <:actions> + <.link patch={~p"/ticket_types/new"}> + <.button>New Ticket Type + + + +
<.modal :if={@live_action in [:new, :edit]} @@ -50,3 +24,45 @@ patch={~p"/ticket_types"} /> + + + + + + + + + + + + <%= for ticket_type <- @ticket_types do %> + + + + + + + + <% end %> + +
NameDescriptionPriceActions
<%= ticket_type.name %><%= ticket_type.description %><%= ticket_type.price %> +
+ <.link + id={"ticket_types-#{ticket_type.id}"} + patch={~p"/ticket_types/#{ticket_type}/edit"} + > + Edit + + + + <.link + phx-click={ + JS.push("delete", value: %{id: ticket_type.id}) |> hide("##{ticket_type.id}") + } + data-confirm="Are you sure?" + > + Delete + + +
+
diff --git a/lib/elixir_conf_africa_web/live/ticket_type_live/show.ex b/lib/elixir_conf_africa_web/live/ticket_type_live/show.ex deleted file mode 100644 index c07f168..0000000 --- a/lib/elixir_conf_africa_web/live/ticket_type_live/show.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule ElixirConfAfricaWeb.TicketTypeLive.Show do - use ElixirConfAfricaWeb, :live_view - - alias ElixirConfAfrica.TicketTypes - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _, socket) do - {:noreply, - socket - |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:ticket_type, TicketTypes.get_ticket_type!(id))} - end - - defp page_title(:show), do: "Show Ticket type" - defp page_title(:edit), do: "Edit Ticket type" -end diff --git a/lib/elixir_conf_africa_web/live/ticket_type_live/show.html.heex b/lib/elixir_conf_africa_web/live/ticket_type_live/show.html.heex deleted file mode 100644 index 9ec7ed1..0000000 --- a/lib/elixir_conf_africa_web/live/ticket_type_live/show.html.heex +++ /dev/null @@ -1,34 +0,0 @@ -<.header> - Ticket type <%= @ticket_type.id %> - <:subtitle>This is a ticket_type record from your database. - <:actions> - <.link patch={~p"/ticket_types/#{@ticket_type}/show/edit"} phx-click={JS.push_focus()}> - <.button>Edit ticket_type - - - - -<.list> - <:item title="Name"><%= @ticket_type.name %> - <:item title="Number"><%= @ticket_type.number %> - <:item title="Description"><%= @ticket_type.description %> - <:item title="Price"><%= @ticket_type.price %> - - -<.back navigate={~p"/ticket_types"}>Back to ticket_types - -<.modal - :if={@live_action == :edit} - id="ticket_type-modal" - show - on_cancel={JS.patch(~p"/ticket_types/#{@ticket_type}")} -> - <.live_component - module={ElixirConfAfricaWeb.TicketTypeLive.FormComponent} - id={@ticket_type.id} - title={@page_title} - action={@live_action} - ticket_type={@ticket_type} - patch={~p"/ticket_types/#{@ticket_type}"} - /> - diff --git a/lib/elixir_conf_africa_web/live/transaction_live/index.ex b/lib/elixir_conf_africa_web/live/transaction_live/index.ex new file mode 100644 index 0000000..97894cf --- /dev/null +++ b/lib/elixir_conf_africa_web/live/transaction_live/index.ex @@ -0,0 +1,23 @@ +defmodule ElixirConfAfricaWeb.TransactionLive.Index do + use ElixirConfAfricaWeb, :admin_live_view + + alias ElixirConfAfrica.Paystack + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :index, _params) do + transctions = Paystack.list_transactions() + + socket + |> assign(:transactions, transctions) + |> assign(:page_title, "Listing Transactions") + end +end diff --git a/lib/elixir_conf_africa_web/live/transaction_live/index.html.heex b/lib/elixir_conf_africa_web/live/transaction_live/index.html.heex new file mode 100644 index 0000000..c0ac271 --- /dev/null +++ b/lib/elixir_conf_africa_web/live/transaction_live/index.html.heex @@ -0,0 +1,34 @@ +
+

Listing Transactions

+
+ + + + + + + + + + + + + + + + <%= for transaction <- @transactions do %> + + + + + + + + + + + <% end %> + +
ReferenceAmountStatusCurrencyEmailBank + Paid At +
<%= transaction["reference"] %><%= transaction["amount"] %><%= transaction["status"] %><%= transaction["currency"] %><%= transaction["email"] %><%= transaction["bank"] %><%= transaction["paid_at"] %>
diff --git a/lib/elixir_conf_africa_web/live/unpaid_ticket_live/index.ex b/lib/elixir_conf_africa_web/live/unpaid_ticket_live/index.ex new file mode 100644 index 0000000..8fcab0c --- /dev/null +++ b/lib/elixir_conf_africa_web/live/unpaid_ticket_live/index.ex @@ -0,0 +1,62 @@ +defmodule ElixirConfAfricaWeb.UnPaidTicketLive.Index do + use ElixirConfAfricaWeb, :admin_live_view + + alias ElixirConfAfrica.Emails + alias ElixirConfAfrica.Tickets + + alias ElixirConfAfrica.Paystack + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :ticket_collection, list_unpaid_tickets())} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + @impl true + def handle_event("update_and_send_ticket", %{"ticketid" => ticketid}, socket) do + ticket = Tickets.get_ticket_by_ticketid!(ticketid) + Emails.deliver_ticket_by_email(ticket.email, "localhost:4900/tickets/#{ticket.ticketid}") + {:ok, _ticket} = Tickets.update_ticket(ticket, %{is_paid: true, email_sent: true}) + + {:noreply, + socket + |> assign(:ticket_collection, list_unpaid_tickets()) + |> put_flash(:info, "Ticket sent successfully")} + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing UnPaid Tickets") + end + + defp list_unpaid_tickets do + Tickets.list_unpaid_tickets() + |> Enum.map(fn ticket -> + %{ + id: ticket.id, + name: ticket.name, + email: ticket.email, + quantity: ticket.quantity, + phone_number: ticket.phone_number, + cost: ticket.cost, + ticketid: ticket.ticketid, + ticket_type_id: ticket.ticket_type_id, + is_paid: ticket.is_paid, + is_refunded: ticket.is_refunded, + ticket_type: ticket.ticket_type, + payment_status: check_payment_status(ticket.ticketid) + } + end) + end + + defp check_payment_status(ticketid) do + case Paystack.test_verification(ticketid) do + %{"status" => "success"} -> "incorrect" + %{"status" => "failed"} -> "correct" + _ -> "correct" + end + end +end diff --git a/lib/elixir_conf_africa_web/live/unpaid_ticket_live/index.html.heex b/lib/elixir_conf_africa_web/live/unpaid_ticket_live/index.html.heex new file mode 100644 index 0000000..9489184 --- /dev/null +++ b/lib/elixir_conf_africa_web/live/unpaid_ticket_live/index.html.heex @@ -0,0 +1,63 @@ +
+

Listing Unpaid Tickets

+
+ + + + + + + + + + + + + + + + <%= for ticket <- @ticket_collection do %> + + + + + + + + + + + + + <% end %> + +
NameEmailTicket idQuantityAmountTypeStatus + Action +
<%= ticket.name %><%= ticket.email %><%= ticket.ticketid %><%= ticket.quantity %><%= ticket.cost %> KSH /=<%= ticket.ticket_type.name %> + <%= if ticket.payment_status == "correct" do %> +
+ Correct +
+ <% else %> +
+ Incorrect +
+ <% end %> +
+ <%= if ticket.payment_status == "incorrect" do %> +
+ +
+ <% end %> +
diff --git a/lib/elixir_conf_africa_web/live/user_live/index.ex b/lib/elixir_conf_africa_web/live/user_live/index.ex new file mode 100644 index 0000000..72e2116 --- /dev/null +++ b/lib/elixir_conf_africa_web/live/user_live/index.ex @@ -0,0 +1,27 @@ +defmodule ElixirConfAfricaWeb.UserLive.Index do + use ElixirConfAfricaWeb, :admin_live_view + + alias ElixirConfAfrica.Accounts + + def mount(_params, session, socket) do + current_user = Accounts.get_user_by_session_token(session["user_token"]) + + users = + Accounts.list_users_apart_from_current_user(current_user) + + {:ok, + socket + |> assign(:users, users) + |> assign(:current_user, current_user)} + end + + def handle_event("change_role", %{"role" => role, "email" => email}, socket) do + user = Accounts.get_user_by_email(email) + {:ok, _user} = Accounts.update_user_role(user, role) + + {:noreply, + socket + |> put_flash(:info, "User role changed successfully") + |> assign(:users, Accounts.list_users_apart_from_current_user(socket.assigns.current_user))} + end +end diff --git a/lib/elixir_conf_africa_web/live/user_live/index.html.heex b/lib/elixir_conf_africa_web/live/user_live/index.html.heex new file mode 100644 index 0000000..645d3e5 --- /dev/null +++ b/lib/elixir_conf_africa_web/live/user_live/index.html.heex @@ -0,0 +1,65 @@ +
+
+

Listing Users

+
+ + + + + + + + + + + + + <%= for user <- @users do %> + + + + + + + + + <% end %> + +
EmailRoleAdmin actionUser actionScanner action
<%= user.email %><%= user.role %> + <%= if user.role != "admin" do %> + + <% end %> + + <%= if user.role != "user" do %> + + <% end %> + + <%= if user.role != "scanner" do %> + + <% end %> +
+
diff --git a/lib/elixir_conf_africa_web/router.ex b/lib/elixir_conf_africa_web/router.ex index b538e48..e87face 100644 --- a/lib/elixir_conf_africa_web/router.ex +++ b/lib/elixir_conf_africa_web/router.ex @@ -1,6 +1,8 @@ defmodule ElixirConfAfricaWeb.Router do use ElixirConfAfricaWeb, :router + import ElixirConfAfricaWeb.UserAuth + pipeline :browser do plug :accepts, ["html"] plug :fetch_session @@ -8,6 +10,7 @@ defmodule ElixirConfAfricaWeb.Router do plug :put_root_layout, html: {ElixirConfAfricaWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_current_user end pipeline :api do @@ -15,25 +18,30 @@ defmodule ElixirConfAfricaWeb.Router do end scope "/", ElixirConfAfricaWeb do - pipe_through :browser - - get "/", PageController, :home + pipe_through [:browser, :require_authenticated_admin] + live "/ticket_types", TicketTypeLive.Index, :index + live "/ticket_types/new", TicketTypeLive.Index, :new + live "/ticket_types/:id/edit", TicketTypeLive.Index, :edit - live "/home", HomeLive.Index, :index + live "/tickets/paid", PaidTicketLive.Index, :index - live "/events", EventLive.Index, :index - live "/events/new", EventLive.Index, :new - live "/events/:id/edit", EventLive.Index, :edit + live "/tickets/refunded", RefundedTicketLive.Index, :index + live "/tickets/unpaid", UnPaidTicketLive.Index, :index + live "/tickets/new", TicketLive.Index, :new + live "/tickets/:id/edit", TicketLive.Index, :edit - live "/events/:id", EventLive.Show, :show - live "/events/:id/show/edit", EventLive.Show, :edit + live "/transactions", TransactionLive.Index, :index + live "/users", UserLive.Index, :index + end - live "/ticket_types", TicketTypeLive.Index, :index - live "/ticket_types/new", TicketTypeLive.Index, :new - live "/ticket_types/:id/edit", TicketTypeLive.Index, :edit + scope "/", ElixirConfAfricaWeb do + pipe_through :browser - live "/ticket_types/:id", TicketTypeLive.Show, :show - live "/ticket_types/:id/show/edit", TicketTypeLive.Show, :edit + live "/", HomeLive.Index, :index + live "/event", EventLive.Index, :index + live "/event/:ticket_type_id/buy", EventLive.Index, :ticket + live "/success", SuccessLive.Index, :index + live "/tickets/:ticketid", TicketLive.Show, :show end # Other scopes may use custom stacks. @@ -57,4 +65,37 @@ defmodule ElixirConfAfricaWeb.Router do forward "/mailbox", Plug.Swoosh.MailboxPreview end end + + ## Authentication routes + + scope "/", ElixirConfAfricaWeb do + pipe_through [:browser, :redirect_if_user_is_authenticated] + + get "/users/register", UserRegistrationController, :new + post "/users/register", UserRegistrationController, :create + get "/users/log_in", UserSessionController, :new + post "/users/log_in", UserSessionController, :create + get "/users/reset_password", UserResetPasswordController, :new + post "/users/reset_password", UserResetPasswordController, :create + get "/users/reset_password/:token", UserResetPasswordController, :edit + put "/users/reset_password/:token", UserResetPasswordController, :update + end + + scope "/", ElixirConfAfricaWeb do + pipe_through [:browser, :require_authenticated_user] + + get "/users/settings", UserSettingsController, :edit + put "/users/settings", UserSettingsController, :update + get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email + end + + scope "/", ElixirConfAfricaWeb do + pipe_through [:browser] + + delete "/users/log_out", UserSessionController, :delete + get "/users/confirm", UserConfirmationController, :new + post "/users/confirm", UserConfirmationController, :create + get "/users/confirm/:token", UserConfirmationController, :edit + post "/users/confirm/:token", UserConfirmationController, :update + end end diff --git a/lib/elixir_conf_africa_web/user_auth.ex b/lib/elixir_conf_africa_web/user_auth.ex new file mode 100644 index 0000000..89ec6e3 --- /dev/null +++ b/lib/elixir_conf_africa_web/user_auth.ex @@ -0,0 +1,240 @@ +defmodule ElixirConfAfricaWeb.UserAuth do + @moduledoc false + use ElixirConfAfricaWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias ElixirConfAfrica.Accounts + + # Make the remember me cookie valid for 60 days. + # If you want bump or reduce this value, also change + # the token expiry itself in UserToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_elixir_conf_africa_web_user_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @doc """ + Logs the user in. + + It renews the session ID and clears the whole session + to avoid fixation attacks. See the renew_session + function to customize this behaviour. + + It also sets a `:live_socket_id` key in the session, + so LiveView sessions are identified and automatically + disconnected on log out. The line can be safely removed + if you are not using LiveView. + """ + def log_in_user(conn, user, params \\ %{}) do + token = Accounts.generate_user_session_token(user) + user_return_to = get_session(conn, :user_return_to) + + conn + |> renew_session() + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do + put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) + end + + defp maybe_write_remember_me_cookie(conn, _token, _params) do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn) do + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn) do + conn + |> configure_session(renew: true) + |> clear_session() + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + ElixirConfAfricaWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: ~p"/") + end + + @doc """ + Authenticates the user by looking into the session + and remember me token. + """ + def fetch_current_user(conn, _opts) do + {user_token, conn} = ensure_user_token(conn) + user = user_token && Accounts.get_user_by_session_token(user_token) + assign(conn, :current_user, user) + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, put_token_in_session(conn, token)} + else + {nil, conn} + end + end + end + + @doc """ + Handles mounting and authenticating the current_user in LiveViews. + + ## `on_mount` arguments + + * `:mount_current_user` - Assigns current_user + to socket assigns based on user_token, or nil if + there's no user_token or no matching user. + + * `:ensure_authenticated` - Authenticates the user from the session, + and assigns the current_user to socket assigns based + on user_token. + Redirects to login page if there's no logged user. + + * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. + Redirects to signed_in_path if there's a logged user. + + ## Examples + + Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate + the current_user: + + defmodule ElixirConfAfricaWeb.PageLive do + use ElixirConfAfricaWeb, :live_view + + on_mount {ElixirConfAfricaWeb.UserAuth, :mount_current_user} + ... + end + + Or use the `live_session` of your router to invoke the on_mount callback: + + live_session :authenticated, on_mount: [{ElixirConfAfricaWeb.UserAuth, :ensure_authenticated}] do + live "/profile", ProfileLive, :index + end + """ + def on_mount(:mount_current_user, _params, session, socket) do + {:cont, mount_current_user(socket, session)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") + + {:halt, socket} + end + end + + def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} + else + {:cont, socket} + end + end + + defp mount_current_user(socket, session) do + Phoenix.Component.assign_new(socket, :current_user, fn -> + if user_token = session["user_token"] do + Accounts.get_user_by_session_token(user_token) + end + end) + end + + @doc """ + Used for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns[:current_user] do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the user to be authenticated. + + If you want to enforce the user email is confirmed before + they use the application at all, here would be a good place. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns[:current_user] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log_in") + |> halt() + end + end + + def require_authenticated_admin(conn, _opts) do + if conn.assigns[:current_user] && conn.assigns[:current_user].role == "admin" do + conn + else + conn + |> put_flash(:error, "You must log in and be authenticated to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log_in") + |> halt() + end + end + + defp put_token_in_session(conn, token) do + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: ~p"/" +end diff --git a/mix.exs b/mix.exs index 70b1b76..bb2f687 100644 --- a/mix.exs +++ b/mix.exs @@ -43,6 +43,7 @@ defmodule ElixirConfAfrica.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:bcrypt_elixir, "~> 3.0"}, {:phoenix, "~> 1.7.7"}, {:phoenix_ecto, "~> 4.4"}, {:ecto_sql, "~> 3.10"}, @@ -65,7 +66,10 @@ defmodule ElixirConfAfrica.MixProject do {:credo, "~> 1.7", only: :dev, runtime: false}, {:excoveralls, "~> 0.18.0", only: :test}, {:faker, "~> 0.17.0", only: [:dev, :test], runtime: false}, - {:typed_ecto_schema, "~> 0.4.1"} + {:typed_ecto_schema, "~> 0.4.1"}, + {:ex_machina, "~> 2.7.0"}, + {:httpoison, "~> 2.1"}, + {:styler, "~> 0.11", only: [:dev, :test], runtime: false} ] end diff --git a/mix.lock b/mix.lock index 48bb7e3..46b7965 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,9 @@ %{ + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"}, "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, + "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, + "comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"}, "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, @@ -10,8 +13,10 @@ "dialyxir": {:hex, :dialyxir, "1.4.2", "764a6e8e7a354f0ba95d58418178d486065ead1f69ad89782817c296d0d746a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "516603d8067b2fd585319e4b13d3674ad4f314a5902ba8130cd97dc902ce6bbd"}, "ecto": {:hex, :ecto, "3.11.0", "ff8614b4e70a774f9d39af809c426def80852048440e8785d93a6e91f48fec00", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7769dad267ef967310d6e988e92d772659b11b09a0c015f101ce0fff81ce1f81"}, "ecto_sql": {:hex, :ecto_sql, "3.11.0", "c787b24b224942b69c9ff7ab9107f258ecdc68326be04815c6cce2941b6fad1c", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "77aa3677169f55c2714dda7352d563002d180eb33c0dc29cd36d39c0a1a971f5"}, + "elixir_make": {:hex, :elixir_make, "0.7.8", "505026f266552ee5aabca0b9f9c229cbb496c689537c9f922f3eb5431157efc7", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "7a71945b913d37ea89b06966e1342c85cfe549b15e6d6d081e8081c493062c07"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, + "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, @@ -19,12 +24,18 @@ "finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"}, "floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"}, "gettext": {:hex, :gettext, "0.23.1", "821e619a240e6000db2fc16a574ef68b3bd7fe0167ccc264a81563cc93e67a31", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "19d744a36b809d810d610b57c27b934425859d158ebd56561bc41f7eeb8795db"}, + "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, + "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"}, "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.10", "02189140a61b2ce85bb633a9b6fd02dff705a5f1596869547aeb2b2b95edd729", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "cf784932e010fd736d656d7fead6a584a4498efefe5b8227e9f383bf15bb79d0"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, @@ -38,12 +49,15 @@ "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, "postgrex": {:hex, :postgrex, "0.17.3", "c92cda8de2033a7585dae8c61b1d420a1a1322421df84da9a82a6764580c503d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "946cf46935a4fdca7a81448be76ba3503cff082df42c6ec1ff16a4bdfbfb098d"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "styler": {:hex, :styler, "0.11.6", "ad4c5fc1ff72b93107ecb251f595b316c6d97604b742f3473aa036888592b270", [:mix], [], "hexpm", "0b0b9936e91b01a7a9fd7239902581ed1cb5515254357126429a37d1bb3d0078"}, "swoosh": {:hex, :swoosh, "1.14.1", "d8813699ba410509008dd3dfdb2df057e3fce367d45d5e6d76b146a7c9d559cd", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87da72260b4351678f96aec61db5c2acc8a88cda2cf2c4f534eb4c9c461350c7"}, "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, "typed_ecto_schema": {:hex, :typed_ecto_schema, "0.4.1", "a373ca6f693f4de84cde474a67467a9cb9051a8a7f3f615f1e23dc74b75237fa", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "85c6962f79d35bf543dd5659c6adc340fd2480cacc6f25d2cc2933ea6e8fcb3b"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"}, } diff --git a/priv/.DS_Store b/priv/.DS_Store index 517548b..4625c3d 100644 Binary files a/priv/.DS_Store and b/priv/.DS_Store differ diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot index ccf5c68..39a220b 100644 --- a/priv/gettext/errors.pot +++ b/priv/gettext/errors.pot @@ -48,23 +48,13 @@ msgid "are still associated with this entry" msgstr "" ## From Ecto.Changeset.validate_length/3 -msgid "should have %{count} item(s)" -msgid_plural "should have %{count} item(s)" -msgstr[0] "" -msgstr[1] "" - msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" msgstr[0] "" msgstr[1] "" -msgid "should be %{count} byte(s)" -msgid_plural "should be %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should have at least %{count} item(s)" -msgid_plural "should have at least %{count} item(s)" +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" msgstr[0] "" msgstr[1] "" @@ -73,13 +63,8 @@ msgid_plural "should be at least %{count} character(s)" msgstr[0] "" msgstr[1] "" -msgid "should be at least %{count} byte(s)" -msgid_plural "should be at least %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should have at most %{count} item(s)" -msgid_plural "should have at most %{count} item(s)" +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" msgstr[0] "" msgstr[1] "" @@ -88,8 +73,8 @@ msgid_plural "should be at most %{count} character(s)" msgstr[0] "" msgstr[1] "" -msgid "should be at most %{count} byte(s)" -msgid_plural "should be at most %{count} byte(s)" +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" msgstr[0] "" msgstr[1] "" diff --git a/priv/repo/migrations/20231004111407_create_events.exs b/priv/repo/migrations/20231004111407_create_events.exs deleted file mode 100644 index dc359f0..0000000 --- a/priv/repo/migrations/20231004111407_create_events.exs +++ /dev/null @@ -1,16 +0,0 @@ -defmodule ElixirConfAfrica.Repo.Migrations.CreateEvents do - use Ecto.Migration - - def change do - create table(:events) do - add :name, :string, null: false - add :event_type, :string, null: false - add :location, :string, null: false - add :description, :text, null: false - add :start_date, :naive_datetime, null: false - add :end_date, :naive_datetime, null: false - - timestamps() - end - end -end diff --git a/priv/repo/migrations/20231006152547_create_ticket_types.exs b/priv/repo/migrations/20231006152547_create_ticket_types.exs deleted file mode 100644 index 9db4dcb..0000000 --- a/priv/repo/migrations/20231006152547_create_ticket_types.exs +++ /dev/null @@ -1,21 +0,0 @@ -defmodule ElixirConfAfrica.Repo.Migrations.CreateTicketTypes do - use Ecto.Migration - - def change do - create table(:ticket_types) do - add :name, :string, null: false - add :description, :text, null: false - add :price, :decimal, null: false - add :number, :integer, null: false - add :event_id, references(:events, on_delete: :nothing), null: false - - timestamps() - end - - create index(:ticket_types, [:event_id]) - - alter table(:events) do - add :ticket_types, references(:ticket_types, on_delete: :nothing) - end - end -end diff --git a/priv/repo/migrations/20231123072523_create_users_auth_tables.exs b/priv/repo/migrations/20231123072523_create_users_auth_tables.exs new file mode 100644 index 0000000..436ffa5 --- /dev/null +++ b/priv/repo/migrations/20231123072523_create_users_auth_tables.exs @@ -0,0 +1,29 @@ +defmodule ElixirConfAfrica.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + create table(:users) do + add :email, :citext, null: false + add :hashed_password, :string, null: false + add :confirmed_at, :naive_datetime + add :role, :string, null: false, default: "user" + + timestamps() + end + + create unique_index(:users, [:email]) + + create table(:users_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + timestamps(updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/priv/repo/migrations/20231130131216_create_ticket_types.exs b/priv/repo/migrations/20231130131216_create_ticket_types.exs new file mode 100644 index 0000000..44995af --- /dev/null +++ b/priv/repo/migrations/20231130131216_create_ticket_types.exs @@ -0,0 +1,16 @@ +defmodule Elixirconf.Repo.Migrations.CreateTicketTypes do + use Ecto.Migration + + def change do + create table(:ticket_types) do + add :name, :string + add :description, :text + add :price, :integer + add :number, :integer + + timestamps() + end + + create unique_index(:ticket_types, [:name]) + end +end diff --git a/priv/repo/migrations/20231130142704_create_ticket.exs b/priv/repo/migrations/20231130142704_create_ticket.exs new file mode 100644 index 0000000..8577a6a --- /dev/null +++ b/priv/repo/migrations/20231130142704_create_ticket.exs @@ -0,0 +1,23 @@ +defmodule Elixirconf.Repo.Migrations.CreateTicket do + use Ecto.Migration + + def change do + create table(:tickets) do + add :name, :string + add :email, :string + add :ticketid, :string + add :quantity, :integer + add :cost, :integer + add :ticket_type_id, references(:ticket_types, on_delete: :nothing) + add :is_paid, :boolean, default: false + add :is_refunded, :boolean, default: false + add :phone_number, :string + add :is_scanned, :boolean, default: false + add :email_sent, :boolean, default: true + + timestamps() + end + + create unique_index(:tickets, [:ticketid]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 8b0ffbe..1d9d4e7 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -5,157 +5,7 @@ # Inside the script, you can read and write to any of your # repositories directly: # -# ElixirConfAfrica.Repo.insert!(%ElixirConfAfrica.SomeSchema{}) +# Elixirconf.Repo.insert!(%Elixirconf.SomeSchema{}) # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. - -alias ElixirConfAfrica.Repo -alias ElixirConfAfrica.Events.Event -alias ElixirConfAfrica.TicketTypes.TicketType - -ticket_types_names = ["Early Bird", "Regular", "VIP/All Access", "Student"] - -# Random seed -Enum.each(1..10, fn _num -> - datetime = Faker.DateTime.forward(Enum.random(1..30)) - - event = - %Event{ - name: Faker.Lorem.sentence(), - event_type: Faker.Lorem.sentence(1), - location: Faker.Address.En.street_address(), - description: Faker.Lorem.paragraph(), - start_date: - datetime - |> NaiveDateTime.truncate(:second), - end_date: - datetime - |> NaiveDateTime.truncate(:second) - |> NaiveDateTime.add(Enum.random(1..5), :day) - } - |> Repo.insert!() - - Enum.each(Enum.shuffle(ticket_types_names), fn type -> - %TicketType{ - event_id: event.id, - name: type, - description: Faker.Lorem.sentence(), - price: 0.0 + Enum.random(20..500), - number: Enum.random(100..500) - } - |> Repo.insert!() - end) -end) - -# Almost random seed -datetime = Faker.DateTime.forward(Enum.random(1..30)) - -event = - %Event{ - name: "ElixirConf 2024", - event_type: "Conference", - location: "Nairobi", - description: "A very long and BEAMy description of some interestingly scary concept", - start_date: - datetime - |> NaiveDateTime.truncate(:second), - end_date: - datetime - |> NaiveDateTime.truncate(:second) - |> NaiveDateTime.add(Enum.random(1..5), :day) - } - |> Repo.insert!() - -Enum.each(Enum.shuffle(ticket_types_names), fn type -> - %TicketType{ - event_id: event.id, - name: type, - description: Faker.Lorem.sentence(), - price: 0.0 + Enum.random(20..500), - number: Enum.random(100..500) - } - |> Repo.insert!() -end) - -datetime = Faker.DateTime.forward(Enum.random(1..30)) - -%Event{ - name: "BEAM: The Perfect Fit for Networks", - event_type: "Webinar", - location: "Zoom", - description: "A very long and BEAMy description of some interestingly scary concept", - start_date: - datetime - |> NaiveDateTime.truncate(:second), - end_date: - datetime - |> NaiveDateTime.truncate(:second) - |> NaiveDateTime.add(Enum.random(1..5), :day) -} -|> Repo.insert!() - -Enum.each(Enum.shuffle(ticket_types_names), fn type -> - %TicketType{ - event_id: event.id, - name: type, - description: Faker.Lorem.sentence(), - price: 0.0 + Enum.random(20..500), - number: Enum.random(100..500) - } - |> Repo.insert!() -end) - -datetime = Faker.DateTime.forward(Enum.random(1..30)) - -%Event{ - name: "Learn You Some Erlang", - event_type: "Webinar", - location: "Somewhere Crazy", - description: "A very long and BEAMy description of some interestingly scary concept", - start_date: NaiveDateTime.truncate(datetime, :second), - end_date: - datetime - |> NaiveDateTime.truncate(:second) - |> NaiveDateTime.add(Enum.random(1..5), :day) -} -|> Repo.insert!() - -Enum.each(Enum.shuffle(ticket_types_names), fn type -> - %TicketType{ - event_id: event.id, - name: type, - description: Faker.Lorem.sentence(), - price: 0.0 + Enum.random(20..500), - number: Enum.random(100..500) - } - |> Repo.insert!() -end) - -datetime = Faker.DateTime.forward(Enum.random(1..30)) - -%Event{ - name: "Who Supervises The Supervisor", - event_type: "Webinar", - location: "Who Knows?", - description: "A very long and BEAMy description of some interestingly scary concept", - start_date: - datetime - |> NaiveDateTime.truncate(:second), - end_date: - datetime - |> NaiveDateTime.truncate(:second) - |> NaiveDateTime.add(Enum.random(1..5), :day) -} -|> Repo.insert!() - -Enum.each(Enum.shuffle(ticket_types_names), fn type -> - %TicketType{ - event_id: event.id, - name: type, - description: Faker.Lorem.sentence(), - price: 0.0 + Enum.random(20..500), - number: Enum.random(100..500) - } - |> Repo.insert!() -end) diff --git a/priv/static/.DS_Store b/priv/static/.DS_Store index 4e12eda..479134f 100644 Binary files a/priv/static/.DS_Store and b/priv/static/.DS_Store differ diff --git a/priv/static/images/.DS_Store b/priv/static/images/.DS_Store new file mode 100644 index 0000000..3bf9fe5 Binary files /dev/null and b/priv/static/images/.DS_Store differ diff --git a/priv/static/images/background.png b/priv/static/images/background.png new file mode 100644 index 0000000..fd733a3 Binary files /dev/null and b/priv/static/images/background.png differ diff --git a/priv/static/images/calendar.svg b/priv/static/images/calendar.svg deleted file mode 100755 index 77cc917..0000000 --- a/priv/static/images/calendar.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/priv/static/images/call.svg b/priv/static/images/call.svg new file mode 100644 index 0000000..2669aee --- /dev/null +++ b/priv/static/images/call.svg @@ -0,0 +1,3 @@ + + + diff --git a/priv/static/images/elixirconflogo.png b/priv/static/images/elixirconflogo.png new file mode 100644 index 0000000..6de70d4 Binary files /dev/null and b/priv/static/images/elixirconflogo.png differ diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg deleted file mode 100644 index 9f26bab..0000000 --- a/priv/static/images/logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/priv/static/images/phoenix.png b/priv/static/images/phoenix.png new file mode 100644 index 0000000..9c81075 Binary files /dev/null and b/priv/static/images/phoenix.png differ diff --git a/priv/static/images/profile.svg b/priv/static/images/profile.svg new file mode 100644 index 0000000..acf47fc --- /dev/null +++ b/priv/static/images/profile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/priv/static/images/purple-elixir-conf.png b/priv/static/images/purple-elixir-conf.png new file mode 100644 index 0000000..eaf3b08 Binary files /dev/null and b/priv/static/images/purple-elixir-conf.png differ diff --git a/priv/static/images/sms.svg b/priv/static/images/sms.svg new file mode 100644 index 0000000..07289b1 --- /dev/null +++ b/priv/static/images/sms.svg @@ -0,0 +1,4 @@ + + + + diff --git a/priv/static/images/visa.png b/priv/static/images/visa.png new file mode 100644 index 0000000..2ddd05d Binary files /dev/null and b/priv/static/images/visa.png differ diff --git a/priv/static/images/visa.svg b/priv/static/images/visa.svg deleted file mode 100644 index 1185ced..0000000 --- a/priv/static/images/visa.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/test/.DS_Store b/test/.DS_Store new file mode 100644 index 0000000..dd0df7d Binary files /dev/null and b/test/.DS_Store differ diff --git a/test/elixir_conf_africa/accounts_test.exs b/test/elixir_conf_africa/accounts_test.exs new file mode 100644 index 0000000..38c2f53 --- /dev/null +++ b/test/elixir_conf_africa/accounts_test.exs @@ -0,0 +1,528 @@ +defmodule ElixirConfAfrica.AccountsTest do + use ElixirConfAfrica.DataCase + + alias ElixirConfAfrica.Accounts + + import ElixirConfAfrica.AccountsFixtures + alias ElixirConfAfrica.Accounts.{User, UserToken} + + describe "get_user_by_email/1" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email("unknown@example.com") + end + + test "returns the user if the email exists" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user_by_email(user.email) + end + end + + describe "get_user_by_email_and_password/2" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the user if the password is not valid" do + user = user_fixture() + refute Accounts.get_user_by_email_and_password(user.email, "invalid") + end + + test "returns the user if the email and password are valid" do + %{id: id} = user = user_fixture() + + assert %User{id: ^id} = + Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end + end + + describe "get_user!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!(-1) + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "register_user/1" do + test "requires email and password to be set" do + {:error, changeset} = Accounts.register_user(%{}) + + assert %{ + password: ["can't be blank"], + email: ["can't be blank"] + } = errors_on(changeset) + end + + test "validates email and password when given" do + {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "poor"}) + + assert %{ + email: ["must have the @ sign and no spaces"], + password: ["should be at least 6 character(s)"] + } = errors_on(changeset) + end + + test "validates maximum values for email and password for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates email uniqueness" do + %{email: email} = user_fixture() + {:error, changeset} = Accounts.register_user(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the upper cased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers users with a hashed password" do + email = unique_user_email() + {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) + assert user.email == email + assert is_binary(user.hashed_password) + assert is_nil(user.confirmed_at) + assert is_nil(user.password) + end + end + + describe "change_user_registration/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{}) + assert changeset.required == [:password, :email] + end + + test "allows fields to be set" do + email = unique_user_email() + password = valid_user_password() + + changeset = + Accounts.change_user_registration( + %User{}, + valid_user_attributes(email: email, password: password) + ) + + assert changeset.valid? + assert get_change(changeset, :email) == email + assert get_change(changeset, :password) == password + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "change_user_email/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "apply_user_email/3" do + setup do + %{user: user_fixture()} + end + + test "requires email to change", %{user: user} do + {:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{}) + assert %{email: ["did not change"]} = errors_on(changeset) + end + + test "validates email", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum value for email for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: too_long}) + + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness", %{user: user} do + %{email: email} = user_fixture() + + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: email}) + + assert "has already been taken" in errors_on(changeset).email + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "applies the email without persisting it", %{user: user} do + email = unique_user_email() + {:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email}) + assert user.email == email + assert Accounts.get_user!(user.id).email != email + end + end + + describe "deliver_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert Accounts.update_user_email(user, token) == :ok + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + assert changed_user.confirmed_at + assert changed_user.confirmed_at != user.confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.update_user_email(user, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "change_user_password/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_password(%User{}, %{ + "password" => "new valid password" + }) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_user_password/3" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "poor", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 6 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, "invalid", %{password: valid_user_password()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "updates the password", %{user: user} do + {:ok, user} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + + {:ok, _} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "delete_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_user_confirmation_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "confirm" + end + end + + describe "confirm_user/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "confirms the email with a valid token", %{user: user, token: token} do + assert {:ok, confirmed_user} = Accounts.confirm_user(token) + assert confirmed_user.confirmed_at + assert confirmed_user.confirmed_at != user.confirmed_at + assert Repo.get!(User, user.id).confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm with invalid token", %{user: user} do + assert Accounts.confirm_user("oops") == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.confirm_user(token) == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "deliver_user_reset_password_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "reset_password" + end + end + + describe "get_user_by_reset_password_token/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "returns the user with valid token", %{user: %{id: id}, token: token} do + assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: id) + end + + test "does not return the user with invalid token", %{user: user} do + refute Accounts.get_user_by_reset_password_token("oops") + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not return the user if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "reset_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.reset_user_password(user, %{ + password: "poor", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 6 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long}) + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{user: user} do + {:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"}) + assert is_nil(updated_user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"}) + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "update a user role/2" do + setup do + %{user: user_fixture()} + end + + test "you can update a user's role" do + {:ok, user} = Accounts.register_user(valid_user_attributes()) + assert user.role == "user" + {:ok, user} = Accounts.update_user_role(user, "admin") + assert user.role == "admin" + end + + test "you can only give the roles 'user' , 'admin' or 'scanner'" do + {:ok, user} = Accounts.register_user(valid_user_attributes()) + assert user.role == "user" + {:error, _} = Accounts.update_user_role(user, "not a role") + assert user.role == "user" + end + end + + describe "inspect/2" do + test "does not include password" do + refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" + end + end +end diff --git a/test/elixir_conf_africa/events_test.exs b/test/elixir_conf_africa/events_test.exs deleted file mode 100644 index db27efe..0000000 --- a/test/elixir_conf_africa/events_test.exs +++ /dev/null @@ -1,110 +0,0 @@ -defmodule ElixirConfAfrica.EventsTest do - use ElixirConfAfrica.DataCase - - alias ElixirConfAfrica.Events - - describe "events" do - import ElixirConfAfrica.Factory - alias ElixirConfAfrica.Events.Event - - @invalid_attrs %{ - name: nil, - description: nil, - location: nil, - event_type: nil, - start_date: nil, - end_date: nil - } - setup do - event = insert!(:elixir_conf_event) - [event: event] - end - - test "list_events/0 returns all events", %{event: event} do - assert Events.list_events() == [event] - end - - test "get_event!/1 returns the event with given id", %{event: event} do - assert Events.get_event!(event.id) == event - end - - test "get_event_with_ticket_types_by_event_name/1 returns the elixir conf event with ticket types", - %{event: event} do - ticket_type = - insert!(:elixir_conf_ticket_type, event_id: event.id) - - event_with_ticket_types = ElixirConfAfrica.Repo.preload(event, :ticket_types) - assert event = Events.get_event_with_ticket_types_by_event_name(event.name) - - assert event_with_ticket_types.ticket_types == [ticket_type] - assert event == event_with_ticket_types - end - - test "create_event/1 with valid data creates a event" do - valid_attrs = %{ - name: "some name", - description: "some description", - location: "some location", - event_type: "some event_type", - start_date: ~N[2023-10-05 06:18:00], - end_date: ~N[2023-10-05 06:18:00] - } - - assert {:ok, %Event{} = event} = Events.create_event(valid_attrs) - assert event.name == "some name" - assert event.description == "some description" - assert event.location == "some location" - assert event.event_type == "some event_type" - assert event.start_date == ~N[2023-10-05 06:18:00] - assert event.end_date == ~N[2023-10-05 06:18:00] - end - - test "create_event/1 with invalid data returns error changeset" do - assert {:error, %Ecto.Changeset{}} = Events.create_event(@invalid_attrs) - end - - test "update_event/2 with valid data updates the event", %{event: event} do - update_attrs = %{ - name: "some updated name", - description: "some updated description", - location: "some updated location", - event_type: "some updated event_type", - start_date: ~N[2023-10-06 06:18:00], - end_date: ~N[2023-10-06 06:18:00] - } - - assert {:ok, %Event{} = event} = Events.update_event(event, update_attrs) - assert event.name == "some updated name" - assert event.description == "some updated description" - assert event.location == "some updated location" - assert event.event_type == "some updated event_type" - assert event.start_date == ~N[2023-10-06 06:18:00] - assert event.end_date == ~N[2023-10-06 06:18:00] - end - - test "update_event/2 with invalid data returns error changeset", %{event: event} do - assert {:error, %Ecto.Changeset{}} = Events.update_event(event, @invalid_attrs) - assert event == Events.get_event!(event.id) - end - - test "delete_event/1 deletes the event", %{event: event} do - assert {:ok, %Event{}} = Events.delete_event(event) - assert_raise Ecto.NoResultsError, fn -> Events.get_event!(event.id) end - end - - test "change_event/1 returns a event changeset", %{event: event} do - assert %Ecto.Changeset{} = Events.change_event(event) - end - - test "get_total_number_of_available_tickets/1 returns total number of tickets", %{ - event: event - } do - %{number: number} = insert!(:elixir_conf_ticket_type, event_id: event.id) - %{number: number1} = insert!(:elixir_conf_ticket_type, event_id: event.id) - total_number_of_available_tickets = number1 + number - - assert Events.get_total_number_of_available_tickets(event.name) == - total_number_of_available_tickets - end - end -end diff --git a/test/elixir_conf_africa/paystack_test.exs b/test/elixir_conf_africa/paystack_test.exs new file mode 100644 index 0000000..992e269 --- /dev/null +++ b/test/elixir_conf_africa/paystack_test.exs @@ -0,0 +1,43 @@ +defmodule ElixirConfAfrica.PaystackTest do + use ElixirConfAfrica.DataCase + alias ElixirConfAfrica.Paystack + + describe "Paystack initialization , transaction verification and getting all transactions" do + setup do + [email: "michaelmunavu83@gmail.com", amount: 1000] + end + + test "initialize/2 with a valid email and amount returns a map with a authorization url , transaction reference and access code", + %{email: email, amount: amount} do + assert %{ + "access_code" => _, + "authorization_url" => _, + "reference" => _ + } = + Paystack.initialize(email, amount) + end + + test "list_transactions/0 returns a list of transactions for that account and gets structured", + %{email: email, amount: amount} do + Paystack.initialize(email, amount) + assert Paystack.list_transactions() != [] + end + + test "test_verification/1 returns a map with the transaction details", %{ + email: email, + amount: amount + } do + %{ + "access_code" => _, + "authorization_url" => _, + "reference" => reference + } = + Paystack.initialize(email, amount) + + assert %{ + "amount" => _amount, + "reference" => _reference + } = Paystack.test_verification(reference) + end + end +end diff --git a/test/elixir_conf_africa/ticket_types_test.exs b/test/elixir_conf_africa/ticket_types_test.exs index f62cd18..237c419 100644 --- a/test/elixir_conf_africa/ticket_types_test.exs +++ b/test/elixir_conf_africa/ticket_types_test.exs @@ -1,82 +1,91 @@ defmodule ElixirConfAfrica.TicketTypesTest do use ElixirConfAfrica.DataCase - alias ElixirConfAfrica.TicketTypes + alias ElixirConfAfrica.Factory describe "ticket_types" do + alias ElixirConfAfrica.TicketTypes alias ElixirConfAfrica.TicketTypes.TicketType - import ElixirConfAfrica.Factory - @invalid_attrs %{name: nil, description: nil, price: nil, number: nil} - setup do - event = insert!(:elixir_conf_event) - ticket_type = insert!(:elixir_conf_ticket_type, event_id: event.id) - %{ticket_type: ticket_type, event: event} - end + @invalid_attrs %{name: nil, description: nil, price: nil} + + test "list_ticket_types/0 returns all ticket_types" do + ticket_type = Factory.insert(:ticket_type) - test "list_ticket_types/0 returns all ticket_types", %{ticket_type: ticket_type} do - assert [^ticket_type] = TicketTypes.list_ticket_types() + assert TicketTypes.list_ticket_types() == [ticket_type] end - test "get_ticket_type!/1 returns the ticket_type with given id", %{ticket_type: ticket_type} do - assert ^ticket_type = TicketTypes.get_ticket_type!(ticket_type.id) + test "get_ticket_type!/1 returns the ticket_type with given id" do + ticket_type = Factory.insert(:ticket_type) + assert TicketTypes.get_ticket_type!(ticket_type.id) == ticket_type end - test "create_ticket_type/1 with valid data creates a ticket_type", %{event: event} do - valid_attrs = %{ - event_id: event.id, - name: "some name", - description: "some description", - price: "120.5", - number: "357" - } + test "create_ticket_type/1 with valid data creates a ticket_type" do + valid_attrs = %{name: "some name", description: "some description", price: 42, number: 49} assert {:ok, %TicketType{} = ticket_type} = TicketTypes.create_ticket_type(valid_attrs) assert ticket_type.name == "some name" assert ticket_type.description == "some description" - assert ticket_type.price == Decimal.new("120.5") + assert ticket_type.price == 42 end test "create_ticket_type/1 with invalid data returns error changeset" do assert {:error, %Ecto.Changeset{}} = TicketTypes.create_ticket_type(@invalid_attrs) end - test "update_ticket_type/2 with valid data updates the ticket_type", %{ - ticket_type: ticket_type - } do + test "update_ticket_type/2 with valid data updates the ticket_type" do + ticket_type = Factory.insert(:ticket_type) + update_attrs = %{ name: "some updated name", description: "some updated description", - price: "456.7", - number: "579" + price: 43, + number: 49 } - assert {:ok, %TicketType{} = update_ticket_type} = + assert {:ok, %TicketType{} = ticket_type} = TicketTypes.update_ticket_type(ticket_type, update_attrs) - refute ticket_type == update_ticket_type - assert update_ticket_type.name == "some updated name" - assert update_ticket_type.description == "some updated description" - assert update_ticket_type.price == Decimal.new("456.7") - assert update_ticket_type.number == 579 + assert ticket_type.name == "some updated name" + assert ticket_type.description == "some updated description" + assert ticket_type.price == 43 end - test "update_ticket_type/2 with invalid data returns error changeset", %{ - ticket_type: ticket_type - } do + test "update_ticket_type/2 with invalid data returns error changeset" do + ticket_type = Factory.insert(:ticket_type) + assert {:error, %Ecto.Changeset{}} = TicketTypes.update_ticket_type(ticket_type, @invalid_attrs) - assert ^ticket_type = TicketTypes.get_ticket_type!(ticket_type.id) + assert ticket_type == TicketTypes.get_ticket_type!(ticket_type.id) end - test "delete_ticket_type/1 deletes the ticket_type", %{ticket_type: ticket_type} do + test "delete_ticket_type/1 deletes the ticket_type" do + ticket_type = Factory.insert(:ticket_type) assert {:ok, %TicketType{}} = TicketTypes.delete_ticket_type(ticket_type) assert_raise Ecto.NoResultsError, fn -> TicketTypes.get_ticket_type!(ticket_type.id) end end - test "change_ticket_type/1 returns a ticket_type changeset", %{ticket_type: ticket_type} do + test "change_ticket_type/1 returns a ticket_type changeset" do + ticket_type = Factory.insert(:ticket_type) assert %Ecto.Changeset{} = TicketTypes.change_ticket_type(ticket_type) end + + test "list_ticket_types_with_remaining_tickets/0 returns all ticket_types with the remaining tickets available" do + ticket_type = Factory.insert(:ticket_type, number: 10) + + Factory.insert(:ticket, ticket_type_id: ticket_type.id, is_paid: true) + + assert TicketTypes.list_ticket_types_with_remaining_tickets() == + [ + %{ + id: ticket_type.id, + name: ticket_type.name, + remaining_tickets: 9, + description: ticket_type.description, + price: ticket_type.price + } + ] + end end end diff --git a/test/elixir_conf_africa/tickets_test.exs b/test/elixir_conf_africa/tickets_test.exs new file mode 100644 index 0000000..f5948e8 --- /dev/null +++ b/test/elixir_conf_africa/tickets_test.exs @@ -0,0 +1,149 @@ +defmodule ElixirConfAfrica.TicketsTest do + use ElixirConfAfrica.DataCase + + alias ElixirConfAfrica.Factory + alias ElixirConfAfrica.Tickets + alias ElixirConfAfrica.Tickets.Ticket + + describe "ticket" do + @invalid_attrs %{name: nil, email: nil, ticketid: nil, quantity: nil} + + setup do + ticket_type = Factory.insert(:ticket_type, number: 10) + + ticket = + Factory.insert(:ticket, + ticket_type_id: ticket_type.id, + is_paid: true, + is_refunded: false, + cost: 400 + ) + + unpaid_ticket = + Factory.insert(:ticket, + ticket_type_id: ticket_type.id, + is_paid: false, + is_refunded: false, + cost: 400 + ) + + refunded_ticket = + Factory.insert(:ticket, + ticket_type_id: ticket_type.id, + is_paid: true, + is_refunded: true, + cost: 400 + ) + + [ + ticket_type: ticket_type, + ticket: ticket, + unpaid_ticket: unpaid_ticket, + refunded_ticket: refunded_ticket + ] + end + + test "list_ticket/0 returns all ticket", %{ + ticket: ticket, + unpaid_ticket: unpaid_ticket, + refunded_ticket: refunded_ticket + } do + assert Tickets.list_ticket() == [ticket, unpaid_ticket, refunded_ticket] + end + + test "list_paid_tickets/0 returns all paid tickets that are not refunded", %{ + ticket: ticket, + ticket_type: ticket_type + } do + assert Tickets.list_paid_tickets() == [ + ticket |> Map.put(:ticket_type, ticket_type) + ] + end + + test "list_unpaid_tickets/0 returns all unpaid tickets that are not refunded", %{ + unpaid_ticket: unpaid_ticket, + ticket_type: ticket_type + } do + assert Tickets.list_unpaid_tickets() == [ + unpaid_ticket |> Map.put(:ticket_type, ticket_type) + ] + end + + test "list_refunded_tickets/0 returns all refunded tickets that are paid", %{ + refunded_ticket: refunded_ticket, + ticket_type: ticket_type + } do + assert Tickets.list_refunded_tickets() == [ + refunded_ticket |> Map.put(:ticket_type, ticket_type) + ] + end + + test "get_ticket!/1 returns the ticket with given id", %{ticket: ticket} do + assert Tickets.get_ticket!(ticket.id) == ticket + end + + test "get_ticket_by_ticketid!/1 returns the ticket with given ticketid", %{ + ticket: ticket, + ticket_type: ticket_type + } do + assert Tickets.get_ticket_by_ticketid!(ticket.ticketid) == + ticket |> Map.put(:ticket_type, ticket_type) + end + + test "create_ticket/1 with valid data creates a ticket", %{ + ticket_type: ticket_type + } do + valid_attrs = %{ + name: "some name", + email: "email@gmail.com", + ticketid: "some ticketid", + quantity: 42, + ticket_type_id: ticket_type.id, + cost: 200 + } + + assert {:ok, %Ticket{} = ticket} = Tickets.create_ticket(valid_attrs) + assert ticket.name == "some name" + assert ticket.email == "email@gmail.com" + assert ticket.ticketid == "some ticketid" + assert ticket.quantity == 42 + end + + test "create_ticket/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Tickets.create_ticket(@invalid_attrs) + end + + test "update_ticket/2 with valid data updates the ticket", %{ + ticket_type: ticket_type, + ticket: ticket + } do + update_attrs = %{ + name: "some updated name", + email: "someemail@gmail.com", + ticketid: "some updated ticketid", + quantity: 43, + cost: 200, + ticket_type_id: ticket_type.id + } + + assert {:ok, %Ticket{} = ticket} = Tickets.update_ticket(ticket, update_attrs) + assert ticket.name == "some updated name" + assert ticket.email == "someemail@gmail.com" + assert ticket.ticketid == "some updated ticketid" + assert ticket.quantity == 43 + end + + test "update_ticket/2 with invalid data returns error changeset", %{ticket: ticket} do + assert {:error, %Ecto.Changeset{}} = Tickets.update_ticket(ticket, @invalid_attrs) + assert ticket == Tickets.get_ticket!(ticket.id) + end + + test "delete_ticket/1 deletes the ticket", %{ticket: ticket} do + assert {:ok, %Ticket{}} = Tickets.delete_ticket(ticket) + end + + test "change_ticket/1 returns a ticket changeset", %{ticket: ticket} do + assert %Ecto.Changeset{} = Tickets.change_ticket(ticket) + end + end +end diff --git a/test/elixir_conf_africa_web/.DS_Store b/test/elixir_conf_africa_web/.DS_Store new file mode 100644 index 0000000..51293b8 Binary files /dev/null and b/test/elixir_conf_africa_web/.DS_Store differ diff --git a/test/elixir_conf_africa_web/controllers/page_controller_test.exs b/test/elixir_conf_africa_web/controllers/page_controller_test.exs deleted file mode 100644 index 566dd2b..0000000 --- a/test/elixir_conf_africa_web/controllers/page_controller_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule ElixirConfAfricaWeb.PageControllerTest do - use ElixirConfAfricaWeb.ConnCase - - test "GET /", %{conn: conn} do - conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" - end -end diff --git a/test/elixir_conf_africa_web/controllers/user_confirmation_controller_test.exs b/test/elixir_conf_africa_web/controllers/user_confirmation_controller_test.exs new file mode 100644 index 0000000..d368d52 --- /dev/null +++ b/test/elixir_conf_africa_web/controllers/user_confirmation_controller_test.exs @@ -0,0 +1,122 @@ +defmodule ElixirConfAfricaWeb.UserConfirmationControllerTest do + use ElixirConfAfricaWeb.ConnCase, async: true + + alias ElixirConfAfrica.Accounts + alias ElixirConfAfrica.Repo + import ElixirConfAfrica.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "GET /users/confirm" do + test "renders the resend confirmation page", %{conn: conn} do + conn = get(conn, ~p"/users/confirm") + response = html_response(conn, 200) + assert response =~ "Resend confirmation instructions" + end + end + + describe "POST /users/confirm" do + @tag :capture_log + test "sends a new confirmation token", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/confirm", %{ + "user" => %{"email" => user.email} + }) + + assert redirected_to(conn) == ~p"/" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" + end + + test "does not send confirmation token if User is confirmed", %{conn: conn, user: user} do + Repo.update!(Accounts.User.confirm_changeset(user)) + + conn = + post(conn, ~p"/users/confirm", %{ + "user" => %{"email" => user.email} + }) + + assert redirected_to(conn) == ~p"/" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + refute Repo.get_by(Accounts.UserToken, user_id: user.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + conn = + post(conn, ~p"/users/confirm", %{ + "user" => %{"email" => "unknown@example.com"} + }) + + assert redirected_to(conn) == ~p"/" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.all(Accounts.UserToken) == [] + end + end + + describe "GET /users/confirm/:token" do + test "renders the confirmation page", %{conn: conn} do + token_path = ~p"/users/confirm/some-token" + conn = get(conn, token_path) + response = html_response(conn, 200) + assert response =~ "Confirm account" + + assert response =~ "action=\"#{token_path}\"" + end + end + + describe "POST /users/confirm/:token" do + test "confirms the given token once", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + conn = post(conn, ~p"/users/confirm/#{token}") + assert redirected_to(conn) == ~p"/" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "User confirmed successfully" + + assert Accounts.get_user!(user.id).confirmed_at + refute get_session(conn, :user_token) + assert Repo.all(Accounts.UserToken) == [] + + # When not logged in + conn = post(conn, ~p"/users/confirm/#{token}") + assert redirected_to(conn) == ~p"/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + # When logged in + conn = + build_conn() + |> log_in_user(user) + |> post(~p"/users/confirm/#{token}") + + assert redirected_to(conn) == ~p"/" + refute Phoenix.Flash.get(conn.assigns.flash, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, user: user} do + conn = post(conn, ~p"/users/confirm/oops") + assert redirected_to(conn) == ~p"/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + refute Accounts.get_user!(user.id).confirmed_at + end + end +end diff --git a/test/elixir_conf_africa_web/controllers/user_registration_controller_test.exs b/test/elixir_conf_africa_web/controllers/user_registration_controller_test.exs new file mode 100644 index 0000000..0c6bd7b --- /dev/null +++ b/test/elixir_conf_africa_web/controllers/user_registration_controller_test.exs @@ -0,0 +1,55 @@ +defmodule ElixirConfAfricaWeb.UserRegistrationControllerTest do + use ElixirConfAfricaWeb.ConnCase, async: true + + import ElixirConfAfrica.AccountsFixtures + + describe "GET /users/register" do + test "renders registration page", %{conn: conn} do + conn = get(conn, ~p"/users/register") + response = html_response(conn, 200) + assert response =~ "Register" + assert response =~ ~p"/users/log_in" + assert response =~ ~p"/users/register" + end + + test "redirects if already logged in", %{conn: conn} do + conn = conn |> log_in_user(user_fixture()) |> get(~p"/users/register") + + assert redirected_to(conn) == ~p"/" + end + end + + describe "POST /users/register" do + @tag :capture_log + test "creates account and logs the user in", %{conn: conn} do + email = unique_user_email() + + conn = + post(conn, ~p"/users/register", %{ + "user" => valid_user_attributes(email: email) + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") + response = html_response(conn, 200) + assert response =~ email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log_out" + end + + test "render errors for invalid data", %{conn: conn} do + conn = + post(conn, ~p"/users/register", %{ + "user" => %{"email" => "with spaces", "password" => "short"} + }) + + response = html_response(conn, 200) + assert response =~ "Register" + assert response =~ "must have the @ sign and no spaces" + assert response =~ "should be at least 6 character" + end + end +end diff --git a/test/elixir_conf_africa_web/controllers/user_reset_password_controller_test.exs b/test/elixir_conf_africa_web/controllers/user_reset_password_controller_test.exs new file mode 100644 index 0000000..dd0b968 --- /dev/null +++ b/test/elixir_conf_africa_web/controllers/user_reset_password_controller_test.exs @@ -0,0 +1,123 @@ +defmodule ElixirConfAfricaWeb.UserResetPasswordControllerTest do + use ElixirConfAfricaWeb.ConnCase, async: true + + alias ElixirConfAfrica.Accounts + alias ElixirConfAfrica.Repo + import ElixirConfAfrica.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "GET /users/reset_password" do + test "renders the reset password page", %{conn: conn} do + conn = get(conn, ~p"/users/reset_password") + response = html_response(conn, 200) + assert response =~ "Forgot your password?" + end + end + + describe "POST /users/reset_password" do + @tag :capture_log + test "sends a new reset password token", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/reset_password", %{ + "user" => %{"email" => user.email} + }) + + assert redirected_to(conn) == ~p"/" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + conn = + post(conn, ~p"/users/reset_password", %{ + "user" => %{"email" => "unknown@example.com"} + }) + + assert redirected_to(conn) == ~p"/" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.all(Accounts.UserToken) == [] + end + end + + describe "GET /users/reset_password/:token" do + setup %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token} + end + + test "renders reset password", %{conn: conn, token: token} do + conn = get(conn, ~p"/users/reset_password/#{token}") + assert html_response(conn, 200) =~ "Reset password" + end + + test "does not render reset password with invalid token", %{conn: conn} do + conn = get(conn, ~p"/users/reset_password/oops") + assert redirected_to(conn) == ~p"/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Reset password link is invalid or it has expired" + end + end + + describe "PUT /users/reset_password/:token" do + setup %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token} + end + + test "resets password once", %{conn: conn, user: user, token: token} do + conn = + put(conn, ~p"/users/reset_password/#{token}", %{ + "user" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(conn) == ~p"/users/log_in" + refute get_session(conn, :user_token) + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "Password reset successfully" + + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + conn = + put(conn, ~p"/users/reset_password/#{token}", %{ + "user" => %{ + "password" => "short", + "password_confirmation" => "does not match" + } + }) + + assert html_response(conn, 200) =~ "something went wrong" + end + + test "does not reset password with invalid token", %{conn: conn} do + conn = put(conn, ~p"/users/reset_password/oops") + assert redirected_to(conn) == ~p"/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Reset password link is invalid or it has expired" + end + end +end diff --git a/test/elixir_conf_africa_web/controllers/user_session_controller_test.exs b/test/elixir_conf_africa_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..f8209fd --- /dev/null +++ b/test/elixir_conf_africa_web/controllers/user_session_controller_test.exs @@ -0,0 +1,99 @@ +defmodule ElixirConfAfricaWeb.UserSessionControllerTest do + use ElixirConfAfricaWeb.ConnCase, async: true + + import ElixirConfAfrica.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "GET /users/log_in" do + test "renders log in page", %{conn: conn} do + conn = get(conn, ~p"/users/log_in") + response = html_response(conn, 200) + assert response =~ "Log in" + assert response =~ ~p"/users/register" + assert response =~ "Forgot your password?" + end + + test "redirects if already logged in", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> get(~p"/users/log_in") + assert redirected_to(conn) == ~p"/" + end + end + + describe "POST /users/log_in" do + test "logs the user in", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") + response = html_response(conn, 200) + assert response =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log_out" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_elixir_conf_africa_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "emits error message with invalid credentials", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => "invalid_password"} + }) + + response = html_response(conn, 200) + assert response =~ "Log in" + assert response =~ "Invalid email or password" + end + end + + describe "DELETE /users/log_out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/elixir_conf_africa_web/controllers/user_settings_controller_test.exs b/test/elixir_conf_africa_web/controllers/user_settings_controller_test.exs new file mode 100644 index 0000000..933a188 --- /dev/null +++ b/test/elixir_conf_africa_web/controllers/user_settings_controller_test.exs @@ -0,0 +1,145 @@ +defmodule ElixirConfAfricaWeb.UserSettingsControllerTest do + use ElixirConfAfricaWeb.ConnCase, async: true + + alias ElixirConfAfrica.Accounts + import ElixirConfAfrica.AccountsFixtures + + setup :register_and_log_in_user + + describe "GET /users/settings" do + test "renders settings page", %{conn: conn} do + conn = get(conn, ~p"/users/settings") + response = html_response(conn, 200) + assert response =~ "Settings" + end + + test "redirects if user is not logged in" do + conn = build_conn() + conn = get(conn, ~p"/users/settings") + assert redirected_to(conn) == ~p"/users/log_in" + end + end + + describe "PUT /users/settings (change password form)" do + test "updates the user password and resets tokens", %{conn: conn, user: user} do + new_password_conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_password", + "current_password" => valid_user_password(), + "user" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not update password on invalid data", %{conn: conn} do + old_password_conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_password", + "current_password" => "invalid", + "user" => %{ + "password" => "short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(old_password_conn, 200) + assert response =~ "Settings" + assert response =~ "should be at least 6 character(s)" + assert response =~ "does not match password" + assert response =~ "is not valid" + + assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token) + end + end + + describe "PUT /users/settings (change email form)" do + @tag :capture_log + test "updates the user email", %{conn: conn, user: user} do + conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_email", + "current_password" => valid_user_password(), + "user" => %{"email" => unique_user_email()} + }) + + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "A link to confirm your email" + + assert Accounts.get_user_by_email(user.email) + end + + test "does not update email on invalid data", %{conn: conn} do + conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_email", + "current_password" => "invalid", + "user" => %{"email" => "with spaces"} + }) + + response = html_response(conn, 200) + assert response =~ "Settings" + assert response =~ "must have the @ sign and no spaces" + assert response =~ "is not valid" + end + end + + describe "GET /users/settings/confirm_email/:token" do + setup %{user: user} do + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{token: token, email: email} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + conn = get(conn, ~p"/users/settings/confirm_email/#{token}") + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "Email changed successfully" + + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + conn = get(conn, ~p"/users/settings/confirm_email/#{token}") + + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Email change link is invalid or it has expired" + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + conn = get(conn, ~p"/users/settings/confirm_email/oops") + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Email change link is invalid or it has expired" + + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + conn = get(conn, ~p"/users/settings/confirm_email/#{token}") + assert redirected_to(conn) == ~p"/users/log_in" + end + end +end diff --git a/test/elixir_conf_africa_web/live/event_live_test.exs b/test/elixir_conf_africa_web/live/event_live_test.exs index 23da999..9983554 100644 --- a/test/elixir_conf_africa_web/live/event_live_test.exs +++ b/test/elixir_conf_africa_web/live/event_live_test.exs @@ -2,129 +2,118 @@ defmodule ElixirConfAfricaWeb.EventLiveTest do use ElixirConfAfricaWeb.ConnCase import Phoenix.LiveViewTest - import ElixirConfAfrica.Factory + alias ElixirConfAfrica.Factory - @create_attrs %{ + @valid_attributes %{ name: "some name", - description: "some description", - location: "some location", - event_type: "some event_type", - start_date: "2023-10-05T06:18:00", - end_date: "2023-10-05T06:18:00" + email: "michaelmunavu83@gmail.com", + quantity: 2 } - @update_attrs %{ - name: "some updated name", - description: "some updated description", - location: "some updated location", - event_type: "some updated event_type", - start_date: "2023-10-06T06:18:00", - end_date: "2023-10-06T06:18:00" - } - @invalid_attrs %{ + + @invalid_attributes %{ name: nil, - description: nil, - location: nil, - event_type: nil, - start_date: nil, - end_date: nil + email: nil, + quantity: 2 } - setup do - event = insert!(:elixir_conf_event) - %{event: event} - end - - describe "Index" do - test "lists all events", %{conn: conn, event: event} do - {:ok, _index_live, html} = live(conn, ~p"/events") - - assert html =~ "Listing Events" - assert html =~ event.name + describe "Event" do + setup do + ticket_type1 = Factory.insert(:ticket_type, number: 10, name: "Early Bird", price: 400) + ticket_type2 = Factory.insert(:ticket_type, number: 10, name: "Advanced", price: 500) + + Factory.insert(:ticket, + ticket_type_id: ticket_type1.id, + is_paid: false, + is_refunded: false, + cost: 400 + ) + + Factory.insert(:ticket, + ticket_type_id: ticket_type2.id, + is_paid: true, + is_refunded: false, + cost: 400 + ) + + Factory.insert(:ticket, + ticket_type_id: ticket_type1.id, + is_paid: true, + is_refunded: false, + cost: 400 + ) + + Factory.insert(:ticket, + ticket_type_id: ticket_type2.id, + is_paid: true, + is_refunded: true, + cost: 400 + ) + + %{ticket_type1: ticket_type1, ticket_type2: ticket_type2} end - test "saves new event", %{conn: conn} do - {:ok, index_live, _html} = live(conn, ~p"/events") - - assert index_live |> element("a", "New Event") |> render_click() =~ - "New Event" - - assert_patch(index_live, ~p"/events/new") + test "You can see available tickets with their remaining quantity", %{ + conn: conn, + ticket_type1: ticket_type1, + ticket_type2: ticket_type2 + } do + {:ok, _index_live, html} = live(conn, ~p"/event") + + assert html =~ ticket_type1.name + assert html =~ ticket_type2.name + assert html =~ "9" + assert html =~ "8" + end - assert index_live - |> form("#event-form", event: @invalid_attrs) - |> render_change() =~ "can't be blank" + test "once you click on get ticket for a particular ticket type , a form popups where you add your details", + %{ + conn: conn, + ticket_type1: ticket_type1 + } do + {:ok, index_live, _html} = live(conn, ~p"/event") - assert index_live - |> form("#event-form", event: @create_attrs) - |> render_submit() + index_live + |> element("#buy-#{ticket_type1.id}-tickets", "Get") + |> render_click() - assert_patch(index_live, ~p"/events") + assert_patch(index_live, ~p"/event/#{ticket_type1.id}/buy") - html = render(index_live) - assert html =~ "Event created successfully" - assert html =~ "some name" + assert has_element?(index_live, "#ticket-form") end - test "updates event in listing", %{conn: conn, event: event} do - {:ok, index_live, _html} = live(conn, ~p"/events") + test "adding invalid data on the form renders errors", + %{ + conn: conn, + ticket_type1: ticket_type1 + } do + {:ok, index_live, _html} = live(conn, ~p"/event") - assert index_live |> element("#events-#{event.id} a", "Edit") |> render_click() =~ - "Edit Event" + index_live + |> element("#buy-#{ticket_type1.id}-tickets", "Get") + |> render_click() - assert_patch(index_live, ~p"/events/#{event}/edit") + {:ok, index_live, _html} = + live(conn, ~p"/event/#{ticket_type1.id}/buy") assert index_live - |> form("#event-form", event: @invalid_attrs) + |> form("#ticket-form", ticket: @invalid_attributes) |> render_change() =~ "can't be blank" - - assert index_live - |> form("#event-form", event: @update_attrs) - |> render_submit() - - assert_patch(index_live, ~p"/events") - - html = render(index_live) - assert html =~ "Event updated successfully" - assert html =~ "some updated name" - end - - test "deletes event in listing", %{conn: conn, event: event} do - {:ok, index_live, _html} = live(conn, ~p"/events") - - assert index_live |> element("#events-#{event.id} a", "Delete") |> render_click() - refute has_element?(index_live, "#events-#{event.id}") end - end - - describe "Show" do - test "displays event", %{conn: conn, event: event} do - {:ok, _show_live, html} = live(conn, ~p"/events/#{event}") - - assert html =~ "Show Event" - assert html =~ event.name - end - - test "updates event within modal", %{conn: conn, event: event} do - {:ok, show_live, _html} = live(conn, ~p"/events/#{event}") - - assert show_live |> element("a", "Edit") |> render_click() =~ - "Edit Event" - - assert_patch(show_live, ~p"/events/#{event}/show/edit") - - assert show_live - |> form("#event-form", event: @invalid_attrs) - |> render_change() =~ "can't be blank" - assert show_live - |> form("#event-form", event: @update_attrs) - |> render_submit() + test "adding valid data on the form redirects to the event page", + %{ + conn: conn, + ticket_type1: ticket_type1 + } do + {:ok, index_live, _html} = live(conn, ~p"/event") - assert_patch(show_live, ~p"/events/#{event}") + index_live + |> element("#buy-#{ticket_type1.id}-tickets", "Get") + |> render_click() - html = render(show_live) - assert html =~ "Event updated successfully" - assert html =~ "some updated name" + index_live + |> form("#ticket-form", ticket: @valid_attributes) + |> render_submit() end end end diff --git a/test/elixir_conf_africa_web/live/home_live_test.exs b/test/elixir_conf_africa_web/live/home_live_test.exs new file mode 100644 index 0000000..391500c --- /dev/null +++ b/test/elixir_conf_africa_web/live/home_live_test.exs @@ -0,0 +1,21 @@ +defmodule ElixirConfAfricaWeb.HomeLiveTest do + use ElixirConfAfricaWeb.ConnCase + + import Phoenix.LiveViewTest + + describe "Home" do + test "There is a button to buy tickets", %{conn: conn} do + {:ok, _index_live, html} = live(conn, ~p"/") + + assert html =~ "Buy Tickets" + end + + test "once clicked, the button takes you to the event page", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/") + + index_live |> element("a", "Buy Tickets") |> render_click() |> follow_redirect(conn) + + assert_redirect(index_live, ~p"/event") + end + end +end diff --git a/test/elixir_conf_africa_web/live/paid_ticket_live_test.exs b/test/elixir_conf_africa_web/live/paid_ticket_live_test.exs new file mode 100644 index 0000000..a169038 --- /dev/null +++ b/test/elixir_conf_africa_web/live/paid_ticket_live_test.exs @@ -0,0 +1,67 @@ +defmodule ElixirConfAfricaWeb.PaidTicketLiveTest do + use ElixirConfAfricaWeb.ConnCase + + import Phoenix.LiveViewTest + alias ElixirConfAfrica.Factory + + describe "Paid Tickets Tests" do + setup %{conn: conn} do + # sign up first + conn = + post(conn, ~p"/users/register", + method: :create, + user: %{email: "admin@gmail.com", password: "123456", role: "admin"} + ) + + ticket_type = Factory.insert(:ticket_type, name: "some name", price: 400) + + paid_ticket = + Factory.insert(:ticket, + is_paid: true, + is_refunded: false, + name: "some paid name", + ticket_type_id: ticket_type.id + ) + + unpaid_ticket = + Factory.insert(:ticket, + is_paid: false, + is_refunded: false, + name: "some unpaid name", + ticket_type_id: ticket_type.id + ) + + [ + paid_ticket: paid_ticket, + unpaid_ticket: unpaid_ticket, + conn: conn + ] + end + + test "you see the paid tickets on /tickets/paid", %{ + conn: conn, + paid_ticket: paid_ticket, + unpaid_ticket: unpaid_ticket + } do + {:ok, _index_live, html} = live(conn, ~p"/tickets/paid") + + assert html =~ "Listing Paid Tickets" + assert html =~ paid_ticket.name + refute html =~ unpaid_ticket.name + end + + test "you see a button on each record that sends a ticket email", %{ + conn: conn, + paid_ticket: paid_ticket + } do + {:ok, index_live, html} = live(conn, ~p"/tickets/paid") + + assert html =~ "Listing Paid Tickets" + assert html =~ paid_ticket.name + + assert index_live + |> element("#send-email-#{paid_ticket.id}", "Send Email") + |> render_click() =~ "Ticket sent successfully" + end + end +end diff --git a/test/elixir_conf_africa_web/live/refunded_ticket_live_test.exs b/test/elixir_conf_africa_web/live/refunded_ticket_live_test.exs new file mode 100644 index 0000000..847cf73 --- /dev/null +++ b/test/elixir_conf_africa_web/live/refunded_ticket_live_test.exs @@ -0,0 +1,53 @@ +defmodule ElixirConfAfricaWeb.RefundedTicketLiveTest do + use ElixirConfAfricaWeb.ConnCase + + import Phoenix.LiveViewTest + alias ElixirConfAfrica.Factory + + describe "Refunded Tickets Tests" do + setup %{conn: conn} do + # sign up first + conn = + post(conn, ~p"/users/register", + method: :create, + user: %{email: "admin@gmail.com", password: "123456", role: "admin"} + ) + + ticket_type = Factory.insert(:ticket_type, name: "some name", price: 400) + + paid_ticket = + Factory.insert(:ticket, + is_paid: true, + is_refunded: false, + name: "some paid name", + ticket_type_id: ticket_type.id + ) + + refunded_ticket = + Factory.insert(:ticket, + is_paid: true, + is_refunded: true, + name: "some refunded name", + ticket_type_id: ticket_type.id + ) + + [ + paid_ticket: paid_ticket, + refunded_ticket: refunded_ticket, + conn: conn + ] + end + + test "you see the refunded tickets on /tickets/refunded", %{ + conn: conn, + paid_ticket: paid_ticket, + refunded_ticket: refunded_ticket + } do + {:ok, _index_live, html} = live(conn, ~p"/tickets/refunded") + + assert html =~ "Listing Refunded Tickets" + assert html =~ refunded_ticket.name + refute html =~ paid_ticket.name + end + end +end diff --git a/test/elixir_conf_africa_web/live/success_live_test.exs b/test/elixir_conf_africa_web/live/success_live_test.exs new file mode 100644 index 0000000..ba94b5e --- /dev/null +++ b/test/elixir_conf_africa_web/live/success_live_test.exs @@ -0,0 +1,55 @@ +defmodule ElixirConfAfricaWeb.SuccessLiveTest do + use ElixirConfAfricaWeb.ConnCase + + import Phoenix.LiveViewTest + alias ElixirConfAfrica.Factory + alias ElixirConfAfrica.Paystack + + describe "Tests the Success Redirect URL that you are redirected to" do + setup do + ticket_type = Factory.insert(:ticket_type, name: "some name", price: 400) + + %{"reference" => reference} = + Paystack.initialize("michaelmunavu83@gmail.com", 400) + + Factory.insert(:ticket, + is_paid: true, + is_refunded: false, + name: "some paid name", + ticket_type_id: ticket_type.id, + ticketid: reference + ) + + [ + reference: reference + ] + end + + test "you see a button to go back to the tickets page if the payment is not successful", %{ + reference: reference, + conn: conn + } do + {:ok, _index_live, html} = + live(conn, ~p"/success?trxref=#{reference}") + + assert html =~ "Payment was unsuccessful , go back to the tickets and purchase" + assert html =~ "Back to Tickets Page" + end + + test "the button for tickets page takes you back to the tickets page", %{ + reference: reference, + conn: conn + } do + {:ok, index_live, _html} = + live(conn, ~p"/success?trxref=#{reference}") + + {:ok, _index_live, html} = + index_live + |> element("a", "Back to Tickets Page") + |> render_click() + |> follow_redirect(conn) + + assert html =~ "Available Tickets" + end + end +end diff --git a/test/elixir_conf_africa_web/live/ticket_show_live_test.exs b/test/elixir_conf_africa_web/live/ticket_show_live_test.exs new file mode 100644 index 0000000..f316f49 --- /dev/null +++ b/test/elixir_conf_africa_web/live/ticket_show_live_test.exs @@ -0,0 +1,38 @@ +defmodule ElixirConfAfricaWeb.TicketShowLiveTest do + use ElixirConfAfricaWeb.ConnCase + + import Phoenix.LiveViewTest + alias ElixirConfAfrica.Factory + + describe "Tests The Ticket Show Page" do + setup do + ticket_type = Factory.insert(:ticket_type, name: "some name", price: 400) + + ticket = + Factory.insert(:ticket, + is_paid: true, + is_refunded: false, + name: "some paid name", + ticket_type_id: ticket_type.id, + ticketid: "some-ticket-id" + ) + + [ + ticket: ticket + ] + end + + test "you see the ticket details on /tickets/:id", %{ticket: ticket, conn: conn} do + {:ok, _index_live, html} = live(conn, ~p"/tickets/#{ticket.ticketid}") + + assert html =~ ticket.name + assert html =~ ticket.email + end + + test "you see a qr code on the ticket show page", %{ticket: ticket, conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/tickets/#{ticket.ticketid}") + + assert has_element?(index_live, "#qrcode") + end + end +end diff --git a/test/elixir_conf_africa_web/live/ticket_type_live_test.exs b/test/elixir_conf_africa_web/live/ticket_type_live_test.exs index ee47ce3..e2be8ef 100644 --- a/test/elixir_conf_africa_web/live/ticket_type_live_test.exs +++ b/test/elixir_conf_africa_web/live/ticket_type_live_test.exs @@ -2,29 +2,34 @@ defmodule ElixirConfAfricaWeb.TicketTypeLiveTest do use ElixirConfAfricaWeb.ConnCase import Phoenix.LiveViewTest - import ElixirConfAfrica.Factory - @create_attrs %{ - name: "some name", - description: "some description", - price: "120.5", - number: "357" - } + alias ElixirConfAfrica.Factory + @update_attrs %{ name: "some updated name", description: "some updated description", - price: "456.7", - number: "579" + price: 43, + number: 50 } - @invalid_attrs %{name: nil, description: nil, price: nil, number: nil} - - setup do - event = insert!(:elixir_conf_event) - ticket_type = insert!(:elixir_conf_ticket_type, event_id: event.id) - %{ticket_type: ticket_type, event: event} - end + @invalid_attrs %{name: nil, description: nil, price: nil} describe "Index" do + setup %{conn: conn} do + # sign up first + conn = + post(conn, ~p"/users/register", + method: :create, + user: %{email: "admin@gmail.com", password: "123456", role: "admin"} + ) + + ticket_type = Factory.insert(:ticket_type) + + [ + ticket_type: ticket_type, + conn: conn + ] + end + test "lists all ticket_types", %{conn: conn, ticket_type: ticket_type} do {:ok, _index_live, html} = live(conn, ~p"/ticket_types") @@ -32,11 +37,11 @@ defmodule ElixirConfAfricaWeb.TicketTypeLiveTest do assert html =~ ticket_type.name end - test "saves new ticket_type", %{conn: conn, event: event} do + test "saves new ticket_type", %{conn: conn} do {:ok, index_live, _html} = live(conn, ~p"/ticket_types") - assert index_live |> element("a", "New Ticket type") |> render_click() =~ - "New Ticket type" + assert index_live |> element("a", "New Ticket Type") |> render_click() =~ + "New Ticket Type" assert_patch(index_live, ~p"/ticket_types/new") @@ -44,24 +49,28 @@ defmodule ElixirConfAfricaWeb.TicketTypeLiveTest do |> form("#ticket_type-form", ticket_type: @invalid_attrs) |> render_change() =~ "can't be blank" - assert index_live - |> form("#ticket_type-form", - ticket_type: Map.merge(@create_attrs, %{event_id: event.id}) - ) - |> render_submit() - - assert_patch(index_live, ~p"/ticket_types") + {:ok, _, html} = + index_live + |> form("#ticket_type-form", + ticket_type: %{ + name: "early bird", + description: "some description", + price: 42, + number: 49 + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/ticket_types") - html = render(index_live) assert html =~ "Ticket type created successfully" - assert html =~ "some name" + assert html =~ "early bird" end test "updates ticket_type in listing", %{conn: conn, ticket_type: ticket_type} do {:ok, index_live, _html} = live(conn, ~p"/ticket_types") - assert index_live |> element("#ticket_types-#{ticket_type.id} a", "Edit") |> render_click() =~ - "Edit Ticket type" + assert index_live |> element("#ticket_types-#{ticket_type.id}", "Edit") |> render_click() =~ + "Edit" assert_patch(index_live, ~p"/ticket_types/#{ticket_type}/edit") @@ -69,13 +78,12 @@ defmodule ElixirConfAfricaWeb.TicketTypeLiveTest do |> form("#ticket_type-form", ticket_type: @invalid_attrs) |> render_change() =~ "can't be blank" - assert index_live - |> form("#ticket_type-form", ticket_type: @update_attrs) - |> render_submit() - - assert_patch(index_live, ~p"/ticket_types") + {:ok, _, html} = + index_live + |> form("#ticket_type-form", ticket_type: @update_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/ticket_types") - html = render(index_live) assert html =~ "Ticket type updated successfully" assert html =~ "some updated name" end @@ -83,43 +91,8 @@ defmodule ElixirConfAfricaWeb.TicketTypeLiveTest do test "deletes ticket_type in listing", %{conn: conn, ticket_type: ticket_type} do {:ok, index_live, _html} = live(conn, ~p"/ticket_types") - assert index_live - |> element("#ticket_types-#{ticket_type.id} a", "Delete") - |> render_click() - - refute has_element?(index_live, "#ticket_types-#{ticket_type.id}") - end - end - - describe "Show" do - test "displays ticket_type", %{conn: conn, ticket_type: ticket_type} do - {:ok, _show_live, html} = live(conn, ~p"/ticket_types/#{ticket_type}") - - assert html =~ "Show Ticket type" - assert html =~ ticket_type.name - end - - test "updates ticket_type within modal", %{conn: conn, ticket_type: ticket_type} do - {:ok, show_live, _html} = live(conn, ~p"/ticket_types/#{ticket_type}") - - assert show_live |> element("a", "Edit") |> render_click() =~ - "Edit Ticket type" - - assert_patch(show_live, ~p"/ticket_types/#{ticket_type}/show/edit") - - assert show_live - |> form("#ticket_type-form", ticket_type: @invalid_attrs) - |> render_change() =~ "can't be blank" - - assert show_live - |> form("#ticket_type-form", ticket_type: @update_attrs) - |> render_submit() - - assert_patch(show_live, ~p"/ticket_types/#{ticket_type}") - - html = render(show_live) - assert html =~ "Ticket type updated successfully" - assert html =~ "some updated name" + assert index_live |> element("#ticket_type-#{ticket_type.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#ticket_type-#{ticket_type.id}") end end end diff --git a/test/elixir_conf_africa_web/live/transaction_live_test.exs b/test/elixir_conf_africa_web/live/transaction_live_test.exs new file mode 100644 index 0000000..cc2e753 --- /dev/null +++ b/test/elixir_conf_africa_web/live/transaction_live_test.exs @@ -0,0 +1,42 @@ +defmodule ElixirConfAfricaWeb.TransactionLiveTest do + use ElixirConfAfricaWeb.ConnCase + + import Phoenix.LiveViewTest + + alias ElixirConfAfrica.Paystack + + describe "This page lists all transactions made" do + setup %{conn: conn} do + # sign up first + conn = + post(conn, ~p"/users/register", + method: :create, + user: %{email: "admin@gmail.com", password: "123456", role: "admin"} + ) + + %{"reference" => reference1} = + Paystack.initialize("michaelmunavu83@gmail.com", 400) + + %{"reference" => reference2} = + Paystack.initialize("michaelmunavu83@gmail.com", 400) + + [ + reference1: reference1, + reference2: reference2, + conn: conn + ] + end + + test "on /transactions , you see all the transactions made", %{ + conn: conn, + reference1: reference1, + reference2: reference2 + } do + {:ok, _index_live, html} = live(conn, ~p"/transactions") + + assert html =~ "Listing Transactions" + assert html =~ reference1 + assert html =~ reference2 + end + end +end diff --git a/test/elixir_conf_africa_web/live/unpaid_ticket_live_test.exs b/test/elixir_conf_africa_web/live/unpaid_ticket_live_test.exs new file mode 100644 index 0000000..d15448b --- /dev/null +++ b/test/elixir_conf_africa_web/live/unpaid_ticket_live_test.exs @@ -0,0 +1,62 @@ +defmodule ElixirConfAfricaWeb.UnpaidTicketLiveTest do + use ElixirConfAfricaWeb.ConnCase + + import Phoenix.LiveViewTest + alias ElixirConfAfrica.Factory + alias ElixirConfAfrica.Paystack + + describe "Unpaid Tickets Tests" do + setup %{conn: conn} do + # sign up first + conn = + post(conn, ~p"/users/register", + method: :create, + user: %{email: "admin@gmail.com", password: "123456", role: "admin"} + ) + + ticket_type = Factory.insert(:ticket_type, name: "some name", price: 400) + + %{"reference" => reference} = + Paystack.initialize("michaelmunavu83@gmail.com", 400) + + paid_ticket = + Factory.insert(:ticket, + is_paid: true, + is_refunded: false, + name: "some paid name", + ticketid: reference, + ticket_type_id: ticket_type.id + ) + + %{"reference" => reference} = + Paystack.initialize("michaelmunavu83@gmail.com", 400) + + unpaid_ticket = + Factory.insert(:ticket, + is_paid: false, + is_refunded: false, + name: "some unpaid name", + ticketid: reference, + ticket_type_id: ticket_type.id + ) + + [ + paid_ticket: paid_ticket, + unpaid_ticket: unpaid_ticket, + conn: conn + ] + end + + test "you see the unpaid tickets on /tickets/unpaid", %{ + conn: conn, + paid_ticket: paid_ticket, + unpaid_ticket: unpaid_ticket + } do + {:ok, _index_live, html} = live(conn, ~p"/tickets/unpaid") + + assert html =~ "Listing Unpaid Tickets" + assert html =~ unpaid_ticket.name + refute html =~ paid_ticket.name + end + end +end diff --git a/test/elixir_conf_africa_web/live/user_live_test.exs b/test/elixir_conf_africa_web/live/user_live_test.exs new file mode 100644 index 0000000..d13c2ad --- /dev/null +++ b/test/elixir_conf_africa_web/live/user_live_test.exs @@ -0,0 +1,83 @@ +defmodule ElixirConfAfricaWeb.UserLiveTest do + use ElixirConfAfricaWeb.ConnCase + + import Phoenix.LiveViewTest + + alias ElixirConfAfrica.Factory + + describe "System Users Page" do + setup %{conn: conn} do + # sign up first + conn = + post(conn, ~p"/users/register", + method: :create, + user: %{email: "admin@gmail.com", password: "123456", role: "admin"} + ) + + user_1 = + Factory.insert(:user, email: "test1@gmail.com", hashed_password: "123456", role: "user") + + scanner_1 = + Factory.insert(:user, + email: "test2@gmail.com", + hashed_password: "123456", + role: "scanner" + ) + + admin_1 = + Factory.insert(:user, email: "admin2@gmail.com", hashed_password: "123456", role: "admin") + + [ + conn: conn, + user_1: user_1, + scanner_1: scanner_1, + admin_1: admin_1 + ] + end + + test "on /users you see all users apart from the current user", %{ + conn: conn, + user_1: user_1, + scanner_1: scanner_1, + admin_1: admin_1 + } do + {:ok, _index_live, html} = live(conn, ~p"/users") + + assert html =~ "Listing Users" + assert html =~ user_1.email + assert html =~ scanner_1.email + assert html =~ admin_1.email + end + + test "for admin users , you can only see a button to make the user a normal user or scanner and not to make them an admin", + %{ + conn: conn, + admin_1: admin_1 + } do + {:ok, index_live, _html} = live(conn, ~p"/users") + + assert has_element?(index_live, "#make-user-#{admin_1.id}") + assert has_element?(index_live, "#make-scanner-#{admin_1.id}") + refute has_element?(index_live, "#make-admin-#{admin_1.id}") + end + + test "you can change a users role when you click a button", %{ + conn: conn, + admin_1: admin_1 + } do + {:ok, index_live, _html} = live(conn, ~p"/users") + + assert has_element?(index_live, "#role-#{admin_1.id}", admin_1.role) + + html = + index_live + |> element("#make-user-#{admin_1.id}") + |> render_click() + + assert html =~ "User role changed successfully" + + assert has_element?(index_live, "#role-#{admin_1.id}", "user") + refute has_element?(index_live, "#role-#{admin_1.id}", admin_1.role) + end + end +end diff --git a/test/elixir_conf_africa_web/user_auth_test.exs b/test/elixir_conf_africa_web/user_auth_test.exs new file mode 100644 index 0000000..d6d70d2 --- /dev/null +++ b/test/elixir_conf_africa_web/user_auth_test.exs @@ -0,0 +1,272 @@ +defmodule ElixirConfAfricaWeb.UserAuthTest do + use ElixirConfAfricaWeb.ConnCase, async: true + + alias ElixirConfAfrica.Accounts + alias ElixirConfAfricaWeb.UserAuth + alias Phoenix.LiveView + import ElixirConfAfrica.AccountsFixtures + + @remember_me_cookie "_elixir_conf_africa_web_user_remember_me" + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, ElixirConfAfricaWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{user: user_fixture(), conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == ~p"/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == 5_184_000 + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "users_sessions:abcdef-token" + ElixirConfAfricaWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> UserAuth.log_out_user() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + end + end + + describe "fetch_current_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) + assert conn.assigns.current_user.id == user.id + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_user([]) + + assert conn.assigns.current_user.id == user.id + assert get_session(conn, :user_token) == user_token + + assert get_session(conn, :live_socket_id) == + "users_sessions:#{Base.url_encode64(user_token)}" + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_user + end + end + + describe "on_mount: mount_current_user" do + test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + + test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount: ensure_authenticated" do + test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "redirects to login page if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + socket = %LiveView.Socket{ + endpoint: ElixirConfAfricaWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + + test "redirects to login page if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + socket = %LiveView.Socket{ + endpoint: ElixirConfAfricaWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount: :redirect_if_user_is_authenticated" do + test "redirects if there is an authenticated user ", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + assert {:halt, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + + test "doesn't redirect if there is no authenticated user", %{conn: conn} do + session = conn |> get_session() + + assert {:cont, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + end + + describe "redirect_if_user_is_authenticated/2" do + test "redirects if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) + assert conn.halted + assert redirected_to(conn) == ~p"/" + end + + test "does not redirect if user is not authenticated", %{conn: conn} do + conn = UserAuth.redirect_if_user_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_user/2" do + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + + assert redirected_to(conn) == ~p"/users/log_in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) + refute conn.halted + refute conn.status + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 7ac359d..ac88334 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,4 +35,30 @@ defmodule ElixirConfAfricaWeb.ConnCase do ElixirConfAfrica.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn}) do + user = ElixirConfAfrica.AccountsFixtures.user_fixture() + %{conn: log_in_user(conn, user), user: user} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user) do + token = ElixirConfAfrica.Accounts.generate_user_session_token(user) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + end end diff --git a/test/support/factory.ex b/test/support/factory.ex index 8fc7e86..f9f02d8 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -1,32 +1,32 @@ defmodule ElixirConfAfrica.Factory do @moduledoc false - alias ElixirConfAfrica.Repo + use ExMachina.Ecto, repo: ElixirConfAfrica.Repo + alias ElixirConfAfrica.Accounts.User + alias ElixirConfAfrica.Tickets.Ticket + alias ElixirConfAfrica.TicketTypes.TicketType - def build(:elixir_conf_event) do - %ElixirConfAfrica.Events.Event{ - name: "ElixirConf Africa 2024", - description: "description", - location: "location", - event_type: "event_type", - start_date: ~N[2023-10-05 06:18:00], - end_date: ~N[2023-10-05 06:18:00] - } - end - - def build(:elixir_conf_ticket_type) do - %ElixirConfAfrica.TicketTypes.TicketType{ + def ticket_type_factory do + %TicketType{ name: "some name", description: "some description", - price: Decimal.new("120.5"), - number: 357 + price: 42, + number: 49 } end - def build(factory_name, attributes) do - factory_name |> build() |> struct!(attributes) + def ticket_factory do + %Ticket{ + ticketid: Integer.to_string(System.unique_integer([:positive])), + email: sequence(:email, fn n -> "email-#{n}@example" end), + cost: 400, + quantity: 1 + } end - def insert!(factory_name, attributes \\ []) do - factory_name |> build(attributes) |> Repo.insert!() + def user_factory do + %User{ + email: "some email", + password: "some password" + } end end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..1829cb5 --- /dev/null +++ b/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,31 @@ +defmodule ElixirConfAfrica.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `ElixirConfAfrica.Accounts` context. + """ + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email(), + password: valid_user_password() + }) + end + + def user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> ElixirConfAfrica.Accounts.register_user() + + user + end + + def extract_user_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end +end