├── .dockerignore
├── .formatter.exs
├── .github
└── pull_request_template.md
├── .gitignore
├── 00-messages.png
├── Dockerfile
├── LICENSE
├── README.md
├── assets
├── css
│ └── app.css
├── js
│ └── app.js
├── package-lock.json
├── package.json
├── tailwind.config.js
└── vendor
│ └── topbar.js
├── config
├── config.exs
├── dev.exs
├── prod.exs
├── runtime.exs
└── test.exs
├── fly.toml
├── lib
├── broadway.ex
├── custom_filters.ex
├── mail_man.ex
├── micro_logger.ex
├── phoenix_00.ex
├── phoenix_00
│ ├── accounts.ex
│ ├── accounts
│ │ ├── user.ex
│ │ ├── user_notifier.ex
│ │ └── user_token.ex
│ ├── application.ex
│ ├── contacts.ex
│ ├── contacts
│ │ └── recipient.ex
│ ├── events.ex
│ ├── events
│ │ └── event.ex
│ ├── logs.ex
│ ├── logs
│ │ └── log.ex
│ ├── mailer.ex
│ ├── messages.ex
│ ├── messages
│ │ ├── email.ex
│ │ ├── email_repo.ex
│ │ ├── message.ex
│ │ ├── message_repo.ex
│ │ ├── services
│ │ │ └── send_email.ex
│ │ └── status_machine.ex
│ ├── release.ex
│ ├── repo.ex
│ └── uuid_schema.ex
├── phoenix_00_web.ex
├── phoenix_00_web
│ ├── components
│ │ ├── core_components.ex
│ │ ├── flop_config.ex
│ │ ├── layouts.ex
│ │ ├── layouts
│ │ │ ├── app.html.heex
│ │ │ └── root.html.heex
│ │ └── timeline.ex
│ ├── controllers
│ │ ├── FallbackController.ex
│ │ ├── email_controller.ex
│ │ ├── email_json.ex
│ │ ├── error_html.ex
│ │ ├── error_json.ex
│ │ ├── page_controller.ex
│ │ ├── page_html.ex
│ │ ├── page_html
│ │ │ └── home.html.heex
│ │ └── user_session_controller.ex
│ ├── endpoint.ex
│ ├── gettext.ex
│ ├── live
│ │ ├── email_live
│ │ │ ├── form_component.ex
│ │ │ ├── index.ex
│ │ │ ├── index.html.heex
│ │ │ ├── show.ex
│ │ │ └── show.html.heex
│ │ ├── log_live
│ │ │ ├── form_component.ex
│ │ │ ├── index.ex
│ │ │ ├── index.html.heex
│ │ │ ├── show.ex
│ │ │ └── show.html.heex
│ │ ├── message_live
│ │ │ ├── form_component.ex
│ │ │ ├── index.ex
│ │ │ ├── index.html.heex
│ │ │ ├── show.ex
│ │ │ └── show.html.heex
│ │ ├── user_confirmation_instructions_live.ex
│ │ ├── user_confirmation_live.ex
│ │ ├── user_forgot_password_live.ex
│ │ ├── user_login_live.ex
│ │ ├── user_registration_live.ex
│ │ ├── user_reset_password_live.ex
│ │ └── user_settings_live.ex
│ ├── router.ex
│ ├── telemetry.ex
│ └── user_auth.ex
└── workers
│ └── send_email.ex
├── litestream.Dockerfile
├── mix.exs
├── mix.lock
├── priv
├── gettext
│ ├── en
│ │ └── LC_MESSAGES
│ │ │ └── errors.po
│ └── errors.pot
├── repo
│ └── migrations
│ │ ├── .formatter.exs
│ │ ├── 20240518132719_create_users_auth_tables.exs
│ │ ├── 20240518134826_create_emails.exs
│ │ ├── 20240524230521_add_oban_jobs_table.exs
│ │ ├── 20240531122709_create_recipients.exs
│ │ ├── 20240531123454_create_messages.exs
│ │ ├── 20240610034222_create_events.exs
│ │ ├── 20240615202700_create_logs.exs
│ │ └── seeds.exs
└── static
│ ├── favicon.ico
│ ├── images
│ └── logo.svg
│ └── robots.txt
├── rel
└── overlays
│ └── bin
│ ├── migrate
│ ├── migrate.bat
│ ├── server
│ └── server.bat
├── scripts
└── litestream-docker.sh
├── sst-env.d.ts
├── sst.config.ts
└── test
├── phoenix_00
├── accounts_test.exs
├── contacts_test.exs
├── events_test.exs
├── logs_test.exs
└── messages_test.exs
├── phoenix_00_web
├── controllers
│ ├── error_html_test.exs
│ ├── error_json_test.exs
│ ├── page_controller_test.exs
│ └── user_session_controller_test.exs
├── live
│ ├── email_live_test.exs
│ ├── log_live_test.exs
│ ├── message_live_test.exs
│ ├── user_confirmation_instructions_live_test.exs
│ ├── user_confirmation_live_test.exs
│ ├── user_forgot_password_live_test.exs
│ ├── user_login_live_test.exs
│ ├── user_registration_live_test.exs
│ ├── user_reset_password_live_test.exs
│ └── user_settings_live_test.exs
└── user_auth_test.exs
├── support
├── conn_case.ex
├── data_case.ex
└── fixtures
│ ├── accounts_fixtures.ex
│ ├── contacts_fixtures.ex
│ ├── events_fixtures.ex
│ ├── logs_fixtures.ex
│ └── messages_fixtures.ex
└── test_helper.exs
/.dockerignore:
--------------------------------------------------------------------------------
1 | # This file excludes paths from the Docker build context.
2 | #
3 | # By default, Docker's build context includes all files (and folders) in the
4 | # current directory. Even if a file isn't copied into the container it is still sent to
5 | # the Docker daemon.
6 | #
7 | # There are multiple reasons to exclude files from the build context:
8 | #
9 | # 1. Prevent nested folders from being copied into the container (ex: exclude
10 | # /assets/node_modules when copying /assets)
11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
12 | # 3. Avoid sending files containing sensitive information
13 | #
14 | # More information on using .dockerignore is available here:
15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file
16 |
17 | .dockerignore
18 |
19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed:
20 | #
21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc
23 | .git
24 | !.git/HEAD
25 | !.git/refs
26 |
27 | # Common development/test artifacts
28 | /cover/
29 | /doc/
30 | /test/
31 | /tmp/
32 | .elixir_ls
33 | .sst
34 | .tmp
35 | # Mix artifacts
36 | /_build/
37 | /deps/
38 | *.ez
39 |
40 | # Generated on crash by the VM
41 | erl_crash.dump
42 |
43 | # Static artifacts - These should be fetched and built inside the Docker image
44 | /assets/node_modules/
45 | /priv/static/assets/
46 | /priv/static/cache_manifest.json
47 |
48 |
49 | 00.db
50 | 00.db-shm
51 | 00.db-wal
52 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto, :ecto_sql, :phoenix],
3 | subdirectories: ["priv/*/migrations"],
4 | plugins: [Phoenix.LiveView.HTMLFormatter],
5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
6 | ]
7 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | I, REPLACE_WITH_YOUR_NAME, give Levi Robertson permission to license my contributions on any terms they like.
2 | I am giving them this license in order to make it possible for them to accept my contributions into their project.
3 |
4 | ***As far as the law allows, my contributions come as is, without any warranty or condition, and I will not be liable to anyone for any damages related to this software or this license, under any kind of legal claim.***
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | .env.docker
3 | .sst
4 | .tmp
5 | # The directory Mix will write compiled artifacts to.
6 | /_build/
7 |
8 | # If you run "mix test --cover", coverage assets end up here.
9 | /cover/
10 |
11 | # The directory Mix downloads your dependencies sources to.
12 | /deps/
13 |
14 | # Where 3rd-party dependencies like ExDoc output generated docs.
15 | /doc/
16 |
17 | # Ignore .fetch files in case you like to edit your project deps locally.
18 | /.fetch
19 |
20 | # If the VM crashes, it generates a dump, let's ignore it too.
21 | erl_crash.dump
22 |
23 | # Also ignore archive artifacts (built via "mix archive.build").
24 | *.ez
25 |
26 | # Temporary files, for example, from tests.
27 | /tmp/
28 |
29 | # Ignore package tarball (built via "mix hex.build").
30 | phoenix_00-*.tar
31 |
32 | # Ignore assets that are produced by build tools.
33 | /priv/static/assets/
34 |
35 | # Ignore digested assets cache.
36 | /priv/static/cache_manifest.json
37 |
38 | # In case you use Node.js/npm, you want to ignore these.
39 | npm-debug.log
40 | /assets/node_modules/
41 |
42 |
43 | 00.db
44 | 00.db-shm
45 | 00.db-wal
46 |
47 | #/rel
48 |
49 | .DS_Store
50 |
--------------------------------------------------------------------------------
/00-messages.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/technomancy-dev/00/16901a9eaf5d51b957d07f0c0c061d6c1027c274/00-messages.png
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
2 | # instead of Alpine to avoid DNS resolution issues in production.
3 | #
4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
5 | # https://hub.docker.com/_/ubuntu?tab=tags
6 | #
7 | # This file is based on these images:
8 | #
9 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
10 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240513-slim - for the release image
11 | # - https://pkgs.org/ - resource for finding needed packages
12 | # - Ex: hexpm/elixir:1.16.2-erlang-26.2.5-debian-bullseye-20240513-slim
13 | #
14 | #
15 | ARG ELIXIR_VERSION=1.16.2
16 | ARG OTP_VERSION=26.2.5
17 | ARG DEBIAN_VERSION=bullseye-20240513-slim
18 |
19 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
20 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
21 |
22 | FROM ${BUILDER_IMAGE} as builder
23 |
24 | # install build dependencies
25 | RUN apt-get update -y && apt-get install -y build-essential git nodejs npm \
26 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
27 |
28 | # prepare build dir
29 | WORKDIR /app
30 |
31 | # install hex + rebar
32 | RUN mix local.hex --force && \
33 | mix local.rebar --force
34 |
35 | # set build ENV
36 | ENV MIX_ENV="prod"
37 | # Without this it is breaking on cross platform builds again https://elixirforum.com/t/mix-deps-get-memory-explosion-when-doing-cross-platform-docker-build/57157/3
38 | ENV ERL_FLAGS="+JPperf true"
39 | # install mix dependencies
40 | COPY mix.exs mix.lock ./
41 | RUN mix deps.get --only $MIX_ENV
42 | RUN mkdir config
43 |
44 | # copy compile-time config files before we compile dependencies
45 | # to ensure any relevant config change will trigger the dependencies
46 | # to be re-compiled.
47 | COPY config/config.exs config/${MIX_ENV}.exs config/
48 | RUN mix deps.compile
49 |
50 | COPY priv priv
51 |
52 | COPY lib lib
53 |
54 | COPY assets assets
55 |
56 | # FOR DAISY
57 | # Install node and npm
58 | # RUN apt-get install -y nodejs
59 | # Added for Daisy UI see this forum answer https://elixirforum.com/t/how-to-get-daisyui-and-phoenix-to-work/46612/9
60 | RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
61 | # compile assets
62 | RUN mix assets.deploy
63 |
64 | # Compile the release
65 | RUN mix compile
66 |
67 | # Changes to config/runtime.exs don't require recompiling the code
68 | COPY config/runtime.exs config/
69 |
70 | COPY rel rel
71 | RUN mix release
72 |
73 | # start a new build stage so that the final image will only contain
74 | # the compiled release and other runtime necessities
75 | FROM ${RUNNER_IMAGE}
76 |
77 | RUN apt-get update -y && \
78 | apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates pandoc \
79 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
80 |
81 | # Set the locale
82 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
83 |
84 | ENV LANG en_US.UTF-8
85 | ENV LANGUAGE en_US:en
86 | ENV LC_ALL en_US.UTF-8
87 |
88 | WORKDIR "/app"
89 | RUN chown nobody /app
90 |
91 | # set runner ENV
92 | ENV MIX_ENV="prod"
93 |
94 | # Only copy the final release from the build stage
95 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/phoenix_00 ./
96 |
97 | USER nobody
98 |
99 | # If using an environment that doesn't automatically reap zombie processes, it is
100 | # advised to add an init process such as tini via `apt-get install`
101 | # above and adding an entrypoint. See https://github.com/krallin/tini for details
102 | # ENTRYPOINT ["/tini", "--"]
103 |
104 | CMD ["/app/bin/server"]
105 |
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss/base";
2 | @import "tailwindcss/components";
3 | @import "tailwindcss/utilities";
4 |
5 | /* This file is for your main application CSS */
6 |
--------------------------------------------------------------------------------
/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel`
2 | // to get started and then uncomment the line below.
3 | // import "./user_socket.js"
4 |
5 | // You can include dependencies in two ways.
6 | //
7 | // The simplest option is to put them in assets/vendor and
8 | // import them using relative paths:
9 | //
10 | // import "../vendor/some-package.js"
11 | //
12 | // Alternatively, you can `npm install some-package --prefix assets` and import
13 | // them using a path starting with the package name:
14 | //
15 | // import "some-package"
16 | //
17 |
18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
19 | import "phoenix_html"
20 | // Establish Phoenix Socket and LiveView configuration.
21 | import {Socket} from "phoenix"
22 | import {LiveSocket} from "phoenix_live_view"
23 | import topbar from "../vendor/topbar"
24 |
25 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
26 | let liveSocket = new LiveSocket("/live", Socket, {
27 | longPollFallbackMs: 2500,
28 | params: {_csrf_token: csrfToken}
29 | })
30 |
31 | // Show progress bar on live navigation and form submits
32 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
33 | window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
34 | window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
35 |
36 | // connect if there are any LiveViews on the page
37 | liveSocket.connect()
38 |
39 | // expose liveSocket on window for web console debug logs and latency simulation:
40 | // >> liveSocket.enableDebug()
41 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
42 | // >> liveSocket.disableLatencySim()
43 | window.liveSocket = liveSocket
44 |
45 |
--------------------------------------------------------------------------------
/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "assets",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "tailwind.config.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "devDependencies": {
13 | "daisyui": "^4.11.1"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/assets/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // See the Tailwind configuration guide for advanced usage
2 | // https://tailwindcss.com/docs/configuration
3 |
4 | const plugin = require("tailwindcss/plugin");
5 | const fs = require("fs");
6 | const path = require("path");
7 |
8 | module.exports = {
9 | content: [
10 | "./js/**/*.js",
11 | "../lib/phoenix_00_web.ex",
12 | "../lib/phoenix_00_web/**/*.*ex",
13 | ],
14 | theme: {
15 | extend: {
16 | colors: {
17 | brand: "#FD4F00",
18 | },
19 | },
20 | },
21 | plugins: [
22 | // require("@tailwindcss/forms"),
23 | // Allows prefixing tailwind classes with LiveView classes to add rules
24 | // only when LiveView classes are applied, for example:
25 | //
26 | //
27 | //
28 | plugin(({ addVariant }) =>
29 | addVariant("phx-no-feedback", [
30 | ".phx-no-feedback&",
31 | ".phx-no-feedback &",
32 | ]),
33 | ),
34 | plugin(({ addVariant }) =>
35 | addVariant("phx-click-loading", [
36 | ".phx-click-loading&",
37 | ".phx-click-loading &",
38 | ]),
39 | ),
40 | plugin(({ addVariant }) =>
41 | addVariant("phx-submit-loading", [
42 | ".phx-submit-loading&",
43 | ".phx-submit-loading &",
44 | ]),
45 | ),
46 | plugin(({ addVariant }) =>
47 | addVariant("phx-change-loading", [
48 | ".phx-change-loading&",
49 | ".phx-change-loading &",
50 | ]),
51 | ),
52 | require("daisyui"),
53 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle
54 | // See your `CoreComponents.icon/1` for more information.
55 | //
56 | plugin(function ({ matchComponents, theme }) {
57 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized");
58 | let values = {};
59 | let icons = [
60 | ["", "/24/outline"],
61 | ["-solid", "/24/solid"],
62 | ["-mini", "/20/solid"],
63 | ["-micro", "/16/solid"],
64 | ];
65 | icons.forEach(([suffix, dir]) => {
66 | fs.readdirSync(path.join(iconsDir, dir)).forEach((file) => {
67 | let name = path.basename(file, ".svg") + suffix;
68 | values[name] = { name, fullPath: path.join(iconsDir, dir, file) };
69 | });
70 | });
71 | matchComponents(
72 | {
73 | hero: ({ name, fullPath }) => {
74 | let content = fs
75 | .readFileSync(fullPath)
76 | .toString()
77 | .replace(/\r?\n|\r/g, "");
78 | let size = theme("spacing.6");
79 | if (name.endsWith("-mini")) {
80 | size = theme("spacing.5");
81 | } else if (name.endsWith("-micro")) {
82 | size = theme("spacing.4");
83 | }
84 | return {
85 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
86 | "-webkit-mask": `var(--hero-${name})`,
87 | mask: `var(--hero-${name})`,
88 | "mask-repeat": "no-repeat",
89 | "background-color": "currentColor",
90 | "vertical-align": "middle",
91 | display: "inline-block",
92 | width: size,
93 | height: size,
94 | };
95 | },
96 | },
97 | { values },
98 | );
99 | }),
100 | ],
101 | daisyui: {
102 | themes: [
103 | "lofi",
104 | {
105 | black: {
106 | ...require("daisyui/src/theming/themes")["black"],
107 | warning: "#f4bf50",
108 | success: "#2cd4bf",
109 | error: "#fb6f85",
110 | },
111 | },
112 | "cmyk",
113 | ],
114 | darkTheme: "black",
115 | },
116 | };
117 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | config :phoenix_00, Phoenix00.Repo, migration_primary_key: [type: :uuid]
11 |
12 | config :phoenix_00,
13 | ecto_repos: [Phoenix00.Repo],
14 | generators: [timestamp_type: :utc_datetime]
15 |
16 | # Configures the endpoint
17 | config :phoenix_00, Phoenix00Web.Endpoint,
18 | url: [host: "localhost"],
19 | adapter: Bandit.PhoenixAdapter,
20 | render_errors: [
21 | formats: [html: Phoenix00Web.ErrorHTML, json: Phoenix00Web.ErrorJSON],
22 | layout: false
23 | ],
24 | pubsub_server: Phoenix00.PubSub,
25 | live_view: [signing_salt: "BWJZTt5D"]
26 |
27 | # Configures the mailer
28 | #
29 | # By default it uses the "Local" adapter which stores the emails
30 | # locally. You can see the emails in your browser, at "/dev/mailbox".
31 | #
32 | # For production it's recommended to configure a different adapter
33 | # at the `config/runtime.exs`.
34 | # config :phoenix_00, Phoenix00.Mailer, adapter: Swoosh.Adapters.Local
35 |
36 | # Configure esbuild (the version is required)
37 | config :esbuild,
38 | version: "0.17.11",
39 | phoenix_00: [
40 | args:
41 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
42 | cd: Path.expand("../assets", __DIR__),
43 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
44 | ]
45 |
46 | # Configure tailwind (the version is required)
47 | config :tailwind,
48 | version: "3.4.0",
49 | phoenix_00: [
50 | args: ~w(
51 | --config=tailwind.config.js
52 | --input=css/app.css
53 | --output=../priv/static/assets/app.css
54 | ),
55 | cd: Path.expand("../assets", __DIR__)
56 | ]
57 |
58 | # Configures Elixir's Logger
59 | config :logger, :console,
60 | format: "$time $metadata[$level] $message\n",
61 | metadata: [:request_id]
62 |
63 | # Use Jason for JSON parsing in Phoenix
64 | config :phoenix, :json_library, Jason
65 |
66 | config :phoenix_00, Oban,
67 | engine: Oban.Engines.Lite,
68 | queues: [default: 10, mailer: 20],
69 | repo: Phoenix00.Repo
70 |
71 | config :phoenix_00, Phoenix00.Repo,
72 | adapter: Ecto.Adapters.SQLite3,
73 | database: "00.sqlite"
74 |
75 | config :flop, repo: Phoenix00.Repo
76 | # Import environment specific config. This must remain at the bottom
77 | # of this file so it overrides the configuration defined above.
78 | import_config "#{config_env()}.exs"
79 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | config :phoenix_00,
5 | ecto_repos: [Phoenix00.Repo]
6 |
7 | # config :phoenix_00, Phoenix00.Repo,
8 | # adapter: Ecto.Adapters.SQLite3,
9 | # database: "00.sqlite"
10 |
11 | config :phoenix_00, Phoenix00.Repo,
12 | database: Path.expand("../00.db", Path.dirname(__ENV__.file)),
13 | pool_size: 5,
14 | show_sensitive_data_on_connection_error: true
15 |
16 | config :phoenix_00, Phoenix00.Mailer,
17 | adapter: Swoosh.Adapters.AmazonSES,
18 | region: System.get_env("AWS_REGION"),
19 | access_key: System.get_env("AWS_ACCESS_KEY_ID"),
20 | secret: System.get_env("AWS_SECRET_ACCESS_KEY")
21 |
22 | # config :phoenix_00, Phoenix00.Repo,
23 | # username: "postgres",
24 | # password: "postgres",
25 | # hostname: "localhost",
26 | # database: "phoenix_00_dev",
27 | # stacktrace: true,
28 | # show_sensitive_data_on_connection_error: true,
29 | # pool_size: 10
30 |
31 | # For development, we disable any cache and enable
32 | # debugging and code reloading.
33 | #
34 | # The watchers configuration can be used to run external
35 | # watchers to your application. For example, we can use it
36 | # to bundle .js and .css sources.
37 | config :phoenix_00, Phoenix00Web.Endpoint,
38 | # Binding to loopback ipv4 address prevents access from other machines.
39 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
40 | http: [ip: {127, 0, 0, 1}, port: 4000],
41 | check_origin: false,
42 | code_reloader: true,
43 | debug_errors: true,
44 | secret_key_base: "d7+DyWAf3DOS+Qs2le3pUw2yOeqORnRnmHsIMUshZwP5etyWXRjULeGi0AMkihrk",
45 | watchers: [
46 | esbuild: {Esbuild, :install_and_run, [:phoenix_00, ~w(--sourcemap=inline --watch)]},
47 | tailwind: {Tailwind, :install_and_run, [:phoenix_00, ~w(--watch)]}
48 | ]
49 |
50 | # ## SSL Support
51 | #
52 | # In order to use HTTPS in development, a self-signed
53 | # certificate can be generated by running the following
54 | # Mix task:
55 | #
56 | # mix phx.gen.cert
57 | #
58 | # Run `mix help phx.gen.cert` for more information.
59 | #
60 | # The `http:` config above can be replaced with:
61 | #
62 | # https: [
63 | # port: 4001,
64 | # cipher_suite: :strong,
65 | # keyfile: "priv/cert/selfsigned_key.pem",
66 | # certfile: "priv/cert/selfsigned.pem"
67 | # ],
68 | #
69 | # If desired, both `http:` and `https:` keys can be
70 | # configured to run both http and https servers on
71 | # different ports.
72 |
73 | # Watch static and templates for browser reloading.
74 | config :phoenix_00, Phoenix00Web.Endpoint,
75 | live_reload: [
76 | patterns: [
77 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
78 | ~r"priv/gettext/.*(po)$",
79 | ~r"lib/phoenix_00_web/(controllers|live|components)/.*(ex|heex)$"
80 | ]
81 | ]
82 |
83 | # Enable dev routes for dashboard and mailbox
84 | config :phoenix_00, dev_routes: true
85 |
86 | # Do not include metadata nor timestamps in development logs
87 | config :logger, :console, format: "[$level] $message\n"
88 |
89 | # Set a higher stacktrace during development. Avoid configuring such
90 | # in production as building large stacktraces may be expensive.
91 | config :phoenix, :stacktrace_depth, 20
92 |
93 | # Initialize plugs at runtime for faster development compilation
94 | config :phoenix, :plug_init_mode, :runtime
95 |
96 | config :phoenix_live_view,
97 | # Include HEEx debug annotations as HTML comments in rendered markup
98 | debug_heex_annotations: true,
99 | # Enable helpful, but potentially expensive runtime checks
100 | enable_expensive_runtime_checks: true
101 |
102 | # Disable swoosh api client as it is only required for production adapters.
103 | # config :swoosh, :api_client, false
104 | config :swoosh, :api_client, Swoosh.ApiClient.Hackney
105 | # config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Phoenix00.Finch
106 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Note we also include the path to a cache manifest
4 | # containing the digested version of static files. This
5 | # manifest is generated by the `mix assets.deploy` task,
6 | # which you should run after static files are built and
7 | # before starting your production server.
8 | config :phoenix_00, Phoenix00Web.Endpoint,
9 | cache_static_manifest: "priv/static/cache_manifest.json"
10 |
11 | # Configures Swoosh API Client
12 | config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Phoenix00.Finch
13 |
14 | # Disable Swoosh Local Memory Storage
15 | config :swoosh, local: false
16 |
17 | # Do not print debug messages in production
18 | config :logger, level: :info
19 |
20 | # Runtime production configuration, including reading
21 | # of environment variables, is done on config/runtime.exs.
22 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Only in tests, remove the complexity from the password hashing algorithm
4 | config :bcrypt_elixir, :log_rounds, 1
5 |
6 | # Configure your database
7 | #
8 | # The MIX_TEST_PARTITION environment variable can be used
9 | # to provide built-in test partitioning in CI environment.
10 | # Run `mix help test` for more information.
11 | config :phoenix_00, Phoenix00.Repo,
12 | username: "postgres",
13 | password: "postgres",
14 | hostname: "localhost",
15 | database: "phoenix_00_test#{System.get_env("MIX_TEST_PARTITION")}",
16 | pool: Ecto.Adapters.SQL.Sandbox,
17 | pool_size: System.schedulers_online() * 2
18 |
19 | # We don't run a server during test. If one is required,
20 | # you can enable the server option below.
21 | config :phoenix_00, Phoenix00Web.Endpoint,
22 | http: [ip: {127, 0, 0, 1}, port: 4002],
23 | secret_key_base: "SChQQc6AHppJvntBMXScGNoTcjpQtcBrnqfAqLpqU5KLwUgFPwtOY7ScGkhxQ8lI",
24 | server: false
25 |
26 | # In test we don't send emails.
27 | config :phoenix_00, Phoenix00.Mailer, adapter: Swoosh.Adapters.Test
28 |
29 | # Disable swoosh api client as it is only required for production adapters.
30 | config :swoosh, :api_client, false
31 |
32 | # Print only warnings and errors during test
33 | config :logger, level: :warning
34 |
35 | # Initialize plugs at runtime for faster test compilation
36 | config :phoenix, :plug_init_mode, :runtime
37 |
38 | config :phoenix_live_view,
39 | # Enable helpful, but potentially expensive runtime checks
40 | enable_expensive_runtime_checks: true
41 |
42 | config :phoenix_00, Oban, testing: :inline
43 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml app configuration file generated for phoenix-00 on 2024-05-25T16:23:54+02:00
2 | #
3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4 | #
5 |
6 | app = 'phoenix-00'
7 | primary_region = 'mad'
8 | kill_signal = 'SIGTERM'
9 |
10 | [build]
11 |
12 | # [deploy]
13 | # release_command = '/app/bin/migrate'
14 |
15 | [env]
16 | DATABASE_PATH = '/mnt/phoenix_00/phoenix_00.db'
17 | PHX_HOST = 'phoenix-00.fly.dev'
18 | PORT = '8080'
19 |
20 | [http_service]
21 | internal_port = 8080
22 | force_https = true
23 | auto_stop_machines = true
24 | auto_start_machines = true
25 | min_machines_running = 0
26 | processes = ['app']
27 |
28 | [http_service.concurrency]
29 | type = 'connections'
30 | hard_limit = 1000
31 | soft_limit = 1000
32 |
33 | [[vm]]
34 | memory = '1gb'
35 | cpu_kind = 'shared'
36 | cpus = 1
37 |
38 |
39 | [mounts]
40 | source = "phoenix_00" # name of the volume
41 | destination = "/mnt/phoenix_00"
42 |
--------------------------------------------------------------------------------
/lib/broadway.ex:
--------------------------------------------------------------------------------
1 | defmodule SQSBroadway do
2 | use Broadway
3 |
4 | alias Phoenix00.Messages
5 | alias Broadway.Message
6 |
7 | def start_link(_opts) do
8 | sqs_url = System.get_env("SQS_URL")
9 |
10 | Broadway.start_link(__MODULE__,
11 | name: __MODULE__,
12 | producer: [
13 | module:
14 | {BroadwaySQS.Producer,
15 | queue_url: sqs_url,
16 | config: [
17 | region: System.get_env("AWS_REGION")
18 | ]}
19 | ],
20 | processors: [
21 | default: [concurrency: 50]
22 | ],
23 | batchers: [
24 | save: [concurrency: 5, batch_size: 10, batch_timeout: 1000]
25 | ]
26 | )
27 | end
28 |
29 | def handle_message(_processor_name, message, _context) do
30 | message
31 | |> Message.update_data(&process_data/1)
32 | |> Message.put_batcher(:save)
33 | end
34 |
35 | def handle_batch(:save, messages, _batch_info, _context) do
36 | # save all the records.
37 | messages |> Enum.each(fn e -> Messages.recieve_sns(e.data) end)
38 | messages
39 | end
40 |
41 | defp process_data(data) do
42 | Jason.decode!(data)
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/custom_filters.ex:
--------------------------------------------------------------------------------
1 | defmodule CustomFilters do
2 | import Ecto.Query
3 | # Modified from https://hexdocs.pm/flop/Flop.Schema.html#module-custom-fields
4 | # TODO: Maybe use browser local time if SQLite supports passing timezone.
5 | def date_range(query, %Flop.Filter{value: value, op: _op}, opts) do
6 | source = Keyword.fetch!(opts, :source)
7 |
8 | expr =
9 | dynamic(
10 | [r],
11 | fragment("date(?, ?)", field(r, ^source), ^value)
12 | )
13 |
14 | date = Date.utc_today()
15 | conditions = dynamic([r], ^expr >= ^date)
16 |
17 | where(query, ^conditions)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/mail_man.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.MailMan do
2 | alias Phoenix00.Messages
3 | alias Phoenix00.Mailer
4 |
5 | require Phoenix.Component
6 |
7 |
8 |
9 | def letter(email) do
10 | email_map = make_map(email)
11 |
12 | with {:ok, _map} <- Mailer.from_map(email_map) do
13 | {:ok, email_record} = save_email_record(email_map)
14 | email_with_id = Map.merge(email_map, %{"id" => email_record.id})
15 | {:ok, email_with_id}
16 | else
17 | {:error, _error} = error_tuple -> error_tuple
18 | end
19 | end
20 |
21 | def make_map(
22 | %{"to" => _to, "from" => _from, "subject" => _subject, "html" => html, "text" => text} =
23 | email
24 | ) do
25 |
26 | Map.merge(email, %{"html_body" => html, "text_body" => text})
27 | end
28 |
29 | def make_map(%{"to" => _to, "from" => _from, "subject" => _subject, "html" => html} = email) do
30 |
31 | Map.merge(email, %{
32 | "html_body" => html,
33 | "text_body" => html |> render_html_to_plain_text()
34 | })
35 | end
36 |
37 | def make_map(
38 | %{"to" => _to, "from" => _from, "subject" => _subject, "markdown" => markdown} = email
39 | ) do
40 | html = render_markdown_to_html(markdown)
41 |
42 | Map.merge(email, %{
43 | "html_body" => html,
44 | "text_body" => html |> render_html_to_plain_text()
45 | })
46 | end
47 |
48 | def send_letter(email) do
49 | # Email will be record with ID already.
50 | %{email: email}
51 | |> Phoenix00.Workers.SendEmail.new()
52 | |> Oban.insert()
53 | end
54 |
55 | def enqueue_worker(email) do
56 | %{email: email}
57 | |> Phoenix00.Workers.SendEmail.new()
58 | |> Oban.insert()
59 | end
60 |
61 | def add_email_sender(email, sender_id) do
62 | Messages.add_email_sender(email, sender_id)
63 | end
64 |
65 | defp save_email_record(email) do
66 | Messages.create_email(email)
67 | end
68 |
69 | defp render_markdown_to_html(markdown) do
70 | MDEx.to_html(markdown)
71 | end
72 |
73 |
74 | defp render_html_to_plain_text(html) do
75 | case Pandex.html_to_plain(html) do
76 | {:ok, plain} -> plain
77 | error -> error
78 | end
79 | end
80 |
81 | end
82 |
--------------------------------------------------------------------------------
/lib/micro_logger.ex:
--------------------------------------------------------------------------------
1 | defmodule MicroLogger do
2 | require Logger
3 |
4 | def handle_event([:oban, :job, :exception], %{duration: duration}, meta, nil) do
5 | Logger.warning("[#{meta.queue}] #{meta.worker} failed in #{duration}")
6 | Logger.error(meta.error.message)
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/lib/phoenix_00.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00 do
2 | @moduledoc """
3 | Phoenix00 keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/lib/phoenix_00/accounts/user_notifier.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Accounts.UserNotifier do
2 | require Logger
3 | alias Phoenix00.Messages
4 |
5 | # Delivers the email using the application mailer.
6 | defp deliver(recipient, subject, body) do
7 | with email <-
8 | Messages.send_email(%{
9 | "from" => "00 <#{System.get_env("SYSTEM_EMAIL")}>",
10 | "to" => recipient,
11 | "subject" => subject,
12 | "markdown" => body
13 | }) do
14 | {:ok, email}
15 | else
16 | error ->
17 | Logger.error("Error sending system emails.")
18 | Logger.error(error)
19 | :error
20 | end
21 | end
22 |
23 | @doc """
24 | Deliver instructions to confirm account.
25 | """
26 | def deliver_confirmation_instructions(user, url) do
27 | deliver(user.email, "Confirmation instructions", """
28 | # Hi #{user.email},
29 |
30 | You can confirm your account by visiting the URL below:
31 |
32 | [confirm email](#{url})
33 |
34 | If you didn't create an account with us, **please ignore this.**
35 | """)
36 | end
37 |
38 | @doc """
39 | Deliver instructions to reset a user password.
40 | """
41 | def deliver_reset_password_instructions(user, url) do
42 | deliver(user.email, "Reset password instructions", """
43 |
44 | ==============================
45 |
46 | Hi #{user.email},
47 |
48 | You can reset your password by visiting the URL below:
49 |
50 | #{url}
51 |
52 | If you didn't request this change, please ignore this.
53 |
54 | ==============================
55 | """)
56 | end
57 |
58 | @doc """
59 | Deliver instructions to update a user email.
60 | """
61 | def deliver_update_email_instructions(user, url) do
62 | deliver(user.email, "Update email instructions", """
63 |
64 | ==============================
65 |
66 | Hi #{user.email},
67 |
68 | You can change your email by visiting the URL below:
69 |
70 | #{url}
71 |
72 | If you didn't request this change, please ignore this.
73 |
74 | ==============================
75 | """)
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/lib/phoenix_00/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | @impl true
9 | def start(_type, _args) do
10 | # Run migrations needed here for sqlite https://gist.github.com/Copser/af3bf28cf9ae4f42a358d7d0a19f8b5e#problem-2-release_command
11 | Phoenix00.Release.migrate()
12 |
13 | children = [
14 | Phoenix00Web.Telemetry,
15 | Phoenix00.Repo,
16 | {Oban, Application.fetch_env!(:phoenix_00, Oban)},
17 | {DNSCluster, query: Application.get_env(:phoenix_00, :dns_cluster_query) || :ignore},
18 | {Phoenix.PubSub, name: Phoenix00.PubSub},
19 | # Start the Finch HTTP client for sending emails
20 | {Finch, name: Phoenix00.Finch},
21 | {SQSBroadway, []},
22 | # Start a worker by calling: Phoenix00.Worker.start_link(arg)
23 | # {Phoenix00.Worker, arg},
24 | # Start to serve requests, typically the last entry
25 | Phoenix00Web.Endpoint
26 | ]
27 |
28 | # See https://hexdocs.pm/elixir/Supervisor.html
29 | # for other strategies and supported options
30 | opts = [strategy: :one_for_one, name: Phoenix00.Supervisor]
31 | # Oban.Telemetry.attach_default_logger()
32 |
33 | :telemetry.attach("oban-logger", [:oban, :job, :exception], &MicroLogger.handle_event/4, nil)
34 | Supervisor.start_link(children, opts)
35 | end
36 |
37 | # Tell Phoenix to update the endpoint configuration
38 | # whenever the application is updated.
39 | @impl true
40 | def config_change(changed, _new, removed) do
41 | Phoenix00Web.Endpoint.config_change(changed, removed)
42 | :ok
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/phoenix_00/contacts.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Contacts do
2 | @moduledoc """
3 | The Contacts context.
4 | """
5 |
6 | import Ecto.Query, warn: false
7 | alias Phoenix00.Repo
8 |
9 | alias Phoenix00.Contacts.Recipient
10 |
11 | @doc """
12 | Returns the list of recipients.
13 |
14 | ## Examples
15 |
16 | iex> list_recipients()
17 | [%Recipient{}, ...]
18 |
19 | """
20 | def list_recipients do
21 | Repo.all(Recipient)
22 | end
23 |
24 | @doc """
25 | Gets a single recipient.
26 |
27 | Raises `Ecto.NoResultsError` if the Recipient does not exist.
28 |
29 | ## Examples
30 |
31 | iex> get_recipient!(123)
32 | %Recipient{}
33 |
34 | iex> get_recipient!(456)
35 | ** (Ecto.NoResultsError)
36 |
37 | """
38 | def get_recipient!(id), do: Repo.get!(Recipient, id)
39 |
40 | def get_recipients_by_destinations(destinations) do
41 | Repo.all(get_recipient_by_destinations_query(destinations))
42 | end
43 |
44 | def get_recipient_by_destinations_query(destinations),
45 | do: from(r in Recipient, where: r.destination in ^destinations)
46 |
47 | @doc """
48 | Creates a recipient.
49 |
50 | ## Examples
51 |
52 | iex> create_recipient(%{field: value})
53 | {:ok, %Recipient{}}
54 |
55 | iex> create_recipient(%{field: bad_value})
56 | {:error, %Ecto.Changeset{}}
57 |
58 | """
59 | def create_recipient(attrs \\ %{}) do
60 | %Recipient{}
61 | |> Recipient.changeset(attrs)
62 | |> Repo.insert()
63 | end
64 |
65 | def create_or_find_recipient_by_destination(desitnations) when is_list(desitnations) do
66 | Enum.map(desitnations, fn desitnation ->
67 | create_or_find_recipient_by_destination(desitnation)
68 | end)
69 | end
70 |
71 | def create_or_find_recipient_by_destination(attrs) do
72 | query =
73 | from r in Recipient,
74 | where: r.destination == ^attrs.destination
75 |
76 | if !Repo.one(query) do
77 | create_recipient(attrs)
78 | end
79 |
80 | Repo.one(query)
81 | end
82 |
83 | @doc """
84 | Updates a recipient.
85 |
86 | ## Examples
87 |
88 | iex> update_recipient(recipient, %{field: new_value})
89 | {:ok, %Recipient{}}
90 |
91 | iex> update_recipient(recipient, %{field: bad_value})
92 | {:error, %Ecto.Changeset{}}
93 |
94 | """
95 | def update_recipient(%Recipient{} = recipient, attrs) do
96 | recipient
97 | |> Recipient.changeset(attrs)
98 | |> Repo.update()
99 | end
100 |
101 | def update_recipients(recipients_query, attrs) do
102 | Repo.update_all(recipients_query, attrs)
103 | end
104 |
105 | @doc """
106 | Deletes a recipient.
107 |
108 | ## Examples
109 |
110 | iex> delete_recipient(recipient)
111 | {:ok, %Recipient{}}
112 |
113 | iex> delete_recipient(recipient)
114 | {:error, %Ecto.Changeset{}}
115 |
116 | """
117 | def delete_recipient(%Recipient{} = recipient) do
118 | Repo.delete(recipient)
119 | end
120 |
121 | @doc """
122 | Returns an `%Ecto.Changeset{}` for tracking recipient changes.
123 |
124 | ## Examples
125 |
126 | iex> change_recipient(recipient)
127 | %Ecto.Changeset{data: %Recipient{}}
128 |
129 | """
130 | def change_recipient(%Recipient{} = recipient, attrs \\ %{}) do
131 | Recipient.changeset(recipient, attrs)
132 | end
133 | end
134 |
--------------------------------------------------------------------------------
/lib/phoenix_00/contacts/recipient.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Contacts.Recipient do
2 | use Phoenix00.UUIDSchema
3 | import Ecto.Changeset
4 |
5 | schema "recipients" do
6 | field :destination, :string
7 | has_many :messages, Phoenix00.Contacts.Recipient, foreign_key: :recipient_id
8 |
9 | timestamps(type: :utc_datetime)
10 | end
11 |
12 | @doc false
13 | def changeset(recipient, attrs) do
14 | recipient
15 | |> cast(attrs, [:destination])
16 | |> validate_required([:destination])
17 | |> unique_constraint(:destination)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/phoenix_00/events.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Events do
2 | @moduledoc """
3 | The Events context.
4 | """
5 |
6 | import Ecto.Query, warn: false
7 | alias Phoenix00.Repo
8 |
9 | alias Phoenix00.Events.Event
10 |
11 | @doc """
12 | Returns the list of events.
13 |
14 | ## Examples
15 |
16 | iex> list_events()
17 | [%Event{}, ...]
18 |
19 | """
20 | def list_events do
21 | Repo.all(Event)
22 | end
23 |
24 | @doc """
25 | Gets a single event.
26 |
27 | Raises `Ecto.NoResultsError` if the Event does not exist.
28 |
29 | ## Examples
30 |
31 | iex> get_event!(123)
32 | %Event{}
33 |
34 | iex> get_event!(456)
35 | ** (Ecto.NoResultsError)
36 |
37 | """
38 | def get_event!(id), do: Repo.get!(Event, id)
39 |
40 | @doc """
41 | Creates a event.
42 |
43 | ## Examples
44 |
45 | iex> create_event(%{field: value})
46 | {:ok, %Event{}}
47 |
48 | iex> create_event(%{field: bad_value})
49 | {:error, %Ecto.Changeset{}}
50 |
51 | """
52 | def create_event(attrs \\ %{}) do
53 | %Event{}
54 | |> Event.changeset(attrs)
55 | |> Repo.insert()
56 | end
57 |
58 | def create_event_for_message(message, status) do
59 | Ecto.build_assoc(message, :events, status: status)
60 | |> Repo.insert()
61 | end
62 |
63 | @doc """
64 | Updates a event.
65 |
66 | ## Examples
67 |
68 | iex> update_event(event, %{field: new_value})
69 | {:ok, %Event{}}
70 |
71 | iex> update_event(event, %{field: bad_value})
72 | {:error, %Ecto.Changeset{}}
73 |
74 | """
75 | def update_event(%Event{} = event, attrs) do
76 | event
77 | |> Event.changeset(attrs)
78 | |> Repo.update()
79 | end
80 |
81 | @doc """
82 | Deletes a event.
83 |
84 | ## Examples
85 |
86 | iex> delete_event(event)
87 | {:ok, %Event{}}
88 |
89 | iex> delete_event(event)
90 | {:error, %Ecto.Changeset{}}
91 |
92 | """
93 | def delete_event(%Event{} = event) do
94 | Repo.delete(event)
95 | end
96 |
97 | @doc """
98 | Returns an `%Ecto.Changeset{}` for tracking event changes.
99 |
100 | ## Examples
101 |
102 | iex> change_event(event)
103 | %Ecto.Changeset{data: %Event{}}
104 |
105 | """
106 | def change_event(%Event{} = event, attrs \\ %{}) do
107 | Event.changeset(event, attrs)
108 | end
109 | end
110 |
--------------------------------------------------------------------------------
/lib/phoenix_00/events/event.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Events.Event do
2 | use Phoenix00.UUIDSchema
3 | import Ecto.Changeset
4 |
5 | schema "events" do
6 | field :status, :string
7 | belongs_to :message, Phoenix00.Contacts.Recipient, foreign_key: :message_id
8 |
9 | timestamps(type: :utc_datetime)
10 | end
11 |
12 | @doc false
13 | def changeset(event, attrs) do
14 | event
15 | |> cast(attrs, [:status])
16 | |> cast_assoc(:recipient_id, with: &Phoenix00.Contacts.Recipient.changeset/2)
17 | |> validate_required([:status, :recipient_id])
18 | |> validate_required([:status])
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/phoenix_00/logs.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Logs do
2 | @moduledoc """
3 | The Logs context.
4 | """
5 |
6 | import Ecto.Query, warn: false
7 |
8 | alias Phoenix00.Accounts.UserToken
9 | alias Phoenix00.Repo
10 |
11 | alias Phoenix00.Logs.Log
12 |
13 | @doc """
14 | Returns the list of logs.
15 |
16 | ## Examples
17 |
18 | iex> list_logs()
19 | [%Log{}, ...]
20 |
21 | """
22 | def list_logs do
23 | Repo.all(
24 | Log
25 | |> Ecto.Query.join(:inner, [log], token in UserToken, on: log.token_id == token.id)
26 | |> Ecto.Query.select([log, token], %{
27 | log
28 | | token_id: %{name: token.name, id: token.id}
29 | })
30 | )
31 | end
32 |
33 | def list_logs_flop(params) do
34 | Log
35 | |> Ecto.Query.join(:inner, [log], token in UserToken, on: log.token_id == token.id)
36 | |> Flop.validate_and_run(params, for: Log)
37 | end
38 |
39 | @doc """
40 | Gets a single log.
41 |
42 | Raises `Ecto.NoResultsError` if the Log does not exist.
43 |
44 | ## Examples
45 |
46 | iex> get_log!(123)
47 | %Log{}
48 |
49 | iex> get_log!(456)
50 | ** (Ecto.NoResultsError)
51 |
52 | """
53 | def get_log!(id),
54 | do:
55 | Repo.get!(
56 | Log
57 | |> Ecto.Query.join(:inner, [log], token in UserToken, on: log.token_id == token.id)
58 | |> preload(:email)
59 | |> Ecto.Query.select([log, token, email], %{
60 | log
61 | | token_id: %{name: token.name, id: token.id}
62 | }),
63 | id
64 | )
65 |
66 | @doc """
67 | Creates a log.
68 |
69 | ## Examples
70 |
71 | iex> create_log(%{field: value})
72 | {:ok, %Log{}}
73 |
74 | iex> create_log(%{field: bad_value})
75 | {:error, %Ecto.Changeset{}}
76 |
77 | """
78 | def create_log(attrs \\ %{}) do
79 | %Log{}
80 | |> Log.changeset(attrs)
81 | |> Repo.insert()
82 | end
83 |
84 | def create_log_for_key(key, attrs) do
85 | Ecto.build_assoc(key, :logs, attrs)
86 | |> Repo.insert()
87 |
88 | # %Log{}
89 | # |> Log.changeset(attrs)
90 | # |> Repo.insert()
91 | end
92 |
93 | @doc """
94 | Updates a log.
95 |
96 | ## Examples
97 |
98 | iex> update_log(log, %{field: new_value})
99 | {:ok, %Log{}}
100 |
101 | iex> update_log(log, %{field: bad_value})
102 | {:error, %Ecto.Changeset{}}
103 |
104 | """
105 | def update_log(%Log{} = log, attrs) do
106 | log
107 | |> Log.changeset(attrs)
108 | |> Repo.update()
109 | end
110 |
111 | @doc """
112 | Deletes a log.
113 |
114 | ## Examples
115 |
116 | iex> delete_log(log)
117 | {:ok, %Log{}}
118 |
119 | iex> delete_log(log)
120 | {:error, %Ecto.Changeset{}}
121 |
122 | """
123 | def delete_log(%Log{} = log) do
124 | Repo.delete(log)
125 | end
126 |
127 | @doc """
128 | Returns an `%Ecto.Changeset{}` for tracking log changes.
129 |
130 | ## Examples
131 |
132 | iex> change_log(log)
133 | %Ecto.Changeset{data: %Log{}}
134 |
135 | """
136 | def change_log(%Log{} = log, attrs \\ %{}) do
137 | Log.changeset(log, attrs)
138 | end
139 | end
140 |
--------------------------------------------------------------------------------
/lib/phoenix_00/logs/log.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Logs.Log do
2 | use Phoenix00.UUIDSchema
3 | import Ecto.Changeset
4 |
5 | @derive {
6 | Flop.Schema,
7 | filterable: [:status, :source, :method, :date_range, :token_id],
8 | sortable: [:status, :inserted_at],
9 | max_limit: 100,
10 | default_limit: 12,
11 | default_order: %{
12 | order_by: [:inserted_at],
13 | order_directions: [:desc, :asc]
14 | },
15 | adapter_opts: [
16 | custom_fields: [
17 | date_range: [
18 | filter: {CustomFilters, :date_range, [source: :inserted_at, timezone: "-7 days"]},
19 | ecto_type: :string,
20 | operators: [:>=]
21 | ]
22 | ],
23 | join_fields: [
24 | # token_id: [
25 | # binding: :token_id,
26 | # field: :id,
27 | # ecto_type: :id
28 | # ]
29 | ]
30 | ]
31 | }
32 |
33 | schema "logs" do
34 | field :status, :integer
35 | field :request, :map
36 | field :response, :map
37 | field :source, :string
38 | field :method, Ecto.Enum, values: [:get, :head, :post, :put, :delete, :options, :patch]
39 | field :token_id, :string
40 |
41 | belongs_to :email, Phoenix00.Messages.Email
42 | timestamps(type: :utc_datetime)
43 | end
44 |
45 | @doc false
46 | def changeset(log, attrs) do
47 | log
48 | |> cast(attrs, [:status, :source, :method, :response, :request, :token_id, :email_id])
49 | |> validate_required([:status, :source, :method, :token_id])
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/phoenix_00/mailer.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Mailer do
2 | use Swoosh.Mailer, otp_app: :phoenix_00
3 |
4 | @defaults %{
5 | "to" => [],
6 | "from" => "",
7 | "subject" => "",
8 | "bcc" => [],
9 | "cc" => [],
10 | "reply_to" => nil,
11 | "headers" => %{},
12 | "attachments" => [],
13 | "provider_options" => %{}
14 | }
15 |
16 | def from_map(args) do
17 | %{
18 | "to" => to,
19 | "from" => from,
20 | "subject" => subject,
21 | "bcc" => bcc,
22 | "cc" => cc,
23 | "reply_to" => reply_to,
24 | "headers" => headers,
25 | "attachments" => attachments,
26 | "provider_options" => provider_options,
27 | "text_body" => text_body,
28 | "html_body" => html_body
29 | } = Map.merge(@defaults, args)
30 |
31 | opts =
32 | Enum.filter(
33 | [
34 | to: map_to_contact(to),
35 | from: map_to_contact(from),
36 | subject: subject,
37 | bcc: map_to_contact(bcc),
38 | cc: map_to_contact(cc),
39 | reply_to: map_to_contact(reply_to),
40 | headers: headers,
41 | provider_options: provider_options,
42 | text_body: text_body,
43 | html_body: html_body
44 | ],
45 | fn tuple -> elem(tuple, 1) != nil end
46 | )
47 |
48 | email = Swoosh.Email.new(opts)
49 |
50 | wrap_result(Enum.reduce(attachments, email, &add_attachment/2))
51 | end
52 |
53 | defp wrap_result({:error, _reason} = error) do
54 | error
55 | end
56 |
57 | defp wrap_result(result) do
58 | {:ok, result}
59 | end
60 |
61 | defp add_attachment(_attachment, swoosh) when is_tuple(swoosh) do
62 | swoosh
63 | end
64 |
65 | defp add_attachment(attachment, %Swoosh.Email{} = swoosh) do
66 | case attachment["content_type"] do
67 | nil ->
68 | {:error, "Missing content type on attachment."}
69 |
70 | content_type ->
71 | # Validate if the content is a Base64 string and decode if necessary
72 | content =
73 | case is_base64?(attachment["content"]) do
74 | true ->
75 | Base.decode64!(attachment["content"]) # Decode Base64
76 | false ->
77 | attachment["content"] # It keeps the content as is.
78 | end
79 |
80 | swoosh
81 | |> Swoosh.Email.attachment(
82 | Swoosh.Attachment.new({:data, content},
83 | filename: attachment["filename"],
84 | content_type: content_type
85 | )
86 | )
87 | end
88 | end
89 |
90 | # Function to validate string Base64
91 | defp is_base64?(string) when is_bitstring(string) do
92 | case Base.decode64(string, ignore: :whitespace) do
93 | {:ok, decoded} ->
94 | # If encoding it again gives us the original string, it is valid Base64
95 | Base.encode64(decoded) == String.replace(string, ~r/\s+/, "")
96 | :error ->
97 | false
98 | end
99 | end
100 |
101 | defp is_base64?(_), do: false
102 |
103 | defp map_to_contact(info) when is_list(info) do
104 | Enum.map(info, &map_to_contact/1)
105 | end
106 |
107 | defp map_to_contact(info) when is_bitstring(info) do
108 | {nil, info}
109 | end
110 |
111 | defp map_to_contact(info) when is_nil(info) do
112 | nil
113 | end
114 |
115 | defp map_to_contact(%{"name" => name, "email" => email}) do
116 | {name, email}
117 | end
118 | end
119 |
--------------------------------------------------------------------------------
/lib/phoenix_00/messages/email.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Messages.Email do
2 | use Phoenix00.UUIDSchema
3 | import Ecto.Changeset
4 |
5 | @derive {
6 | Flop.Schema,
7 | filterable: [:status, :from, :date_range, :subject],
8 | sortable: [:status, :inserted_at],
9 | max_limit: 100,
10 | default_limit: 12,
11 | default_order: %{
12 | order_by: [:inserted_at],
13 | order_directions: [:desc, :asc]
14 | },
15 | adapter_opts: [
16 | custom_fields: [
17 | date_range: [
18 | filter: {CustomFilters, :date_range, [source: :inserted_at, timezone: "-7 days"]},
19 | ecto_type: :string,
20 | operators: [:>=]
21 | ]
22 | ],
23 | join_fields: [
24 | status: [
25 | binding: :messages,
26 | field: :status,
27 | ecto_type: :string
28 | ]
29 | ]
30 | ]
31 | }
32 |
33 | schema "emails" do
34 | field :status, Ecto.Enum, values: [:pending, :sent, :delivered, :bounced, :complained]
35 | field :to, {:array, :string}
36 | field :cc, {:array, :string}
37 | field :bcc, {:array, :string}
38 | field :reply_to, {:array, :string}
39 | field :body, :string
40 | field :text, :string
41 | field :subject, :string
42 | field :from, :string
43 | field :email_id, :string
44 | field :sender_id, :string
45 | field :sent_by, :id
46 |
47 | # has_many :recipients, Phoenix00.Contacts.Recipient
48 | has_many :messages, Phoenix00.Messages.Message, foreign_key: :transmission
49 | has_many :logs, Phoenix00.Logs.Log
50 | timestamps(type: :utc_datetime)
51 | end
52 |
53 | @doc false
54 | def changeset(email, attrs) do
55 | email
56 | |> cast(attrs, [:to, :from, :sender_id, :body, :cc, :bcc, :reply_to, :subject])
57 | |> validate_required([:to, :from, :sender_id, :body])
58 | |> unique_constraint(:email_id)
59 | end
60 |
61 | def receive_changeset(email, attrs) do
62 | email
63 | |> cast(attrs, [:to, :from, :body, :text, :cc, :bcc, :reply_to, :subject])
64 | |> validate_required([:to, :from, :body, :text])
65 | end
66 |
67 | def send_changeset(email, attrs) do
68 | email
69 | |> cast(attrs, [:sender_id])
70 | |> validate_required([:sender_id])
71 | |> unique_constraint(:sender_id)
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/lib/phoenix_00/messages/email_repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Messages.EmailRepo do
2 | # use Phoenix00, :repository
3 | import Ecto.Query, warn: false
4 | alias Phoenix00.Repo
5 | alias Phoenix00.Messages.Email
6 |
7 | def list_emails(lim \\ 10, off \\ 0, order \\ [desc: :updated_at]) do
8 | Email |> limit(^lim) |> offset(^off) |> order_by(^order) |> Repo.all()
9 | end
10 |
11 | def get_email!(id),
12 | do:
13 | Repo.get!(
14 | Email
15 | |> preload(:logs)
16 | |> preload(:messages)
17 | |> preload(messages: :recipient)
18 | |> preload(messages: :events),
19 | id
20 | )
21 |
22 | def get_email_by_email_id!(email_id) do
23 | Repo.get_by!(Email, email_id: email_id)
24 | end
25 |
26 | def add_email_sender(%Email{} = email, attrs) do
27 | change_send_email(email, attrs)
28 | |> Repo.update()
29 | end
30 |
31 | def get_email_by_aws_id(aws_message_id) do
32 | Repo.get_by(Email, sender_id: aws_message_id)
33 | end
34 |
35 | def email_count() do
36 | Repo.aggregate(Email, :count, :id)
37 | end
38 |
39 | def create_email(attrs \\ %{}) do
40 | change_email(%Email{}, attrs)
41 | |> Repo.insert()
42 | end
43 |
44 | def receive_email_request(attrs \\ %{}) do
45 | change_receive_email_request(%Email{}, attrs)
46 | |> Repo.insert()
47 | end
48 |
49 | def find_or_create_email_record_by_ses_message(message) do
50 | case get_email_by_aws_id(message["mail"]["messageId"]) do
51 | nil ->
52 | {:ok, email} =
53 | create_email(%{
54 | sender_id: message["mail"]["messageId"],
55 | to: List.flatten([Enum.at(message["mail"]["commonHeaders"]["to"], 0)]),
56 | from: Enum.at(message["mail"]["commonHeaders"]["from"], 0),
57 | status: get_status_from_event_type(message["eventType"]),
58 | email_id: message["mail"]["messageId"],
59 | body:
60 | "
This message was not sent via 00, therefore we did not collect the body. "
61 | })
62 |
63 | email
64 |
65 | email ->
66 | email
67 | end
68 | end
69 |
70 | def update_email(%Email{} = email, attrs) do
71 | change_email(email, attrs)
72 | |> Repo.update()
73 | end
74 |
75 | def update_email(attrs) do
76 | change_email(%Email{}, attrs)
77 | |> Repo.update()
78 | end
79 |
80 | def delete_email(%Email{} = email) do
81 | Repo.delete(email)
82 | end
83 |
84 | def change_email(%Email{} = email, attrs \\ %{}) do
85 | Email.changeset(email, attrs)
86 | end
87 |
88 | def change_send_email(%Email{} = email, attrs \\ %{}) do
89 | Email.send_changeset(email, attrs)
90 | end
91 |
92 | def change_receive_email_request(%Email{} = email, attrs \\ %{}) do
93 | Email.receive_changeset(email, attrs)
94 | end
95 |
96 | defp get_status_from_event_type(event_type) do
97 | case event_type do
98 | "Bounce" -> "bounced"
99 | "Complaint" -> "complained"
100 | "Send" -> "sent"
101 | "Pending" -> "pending"
102 | "Delivery" -> "delivered"
103 | end
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/lib/phoenix_00/messages/message.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Messages.Message do
2 | use Phoenix00.UUIDSchema
3 | import Ecto.Changeset
4 |
5 | @derive {
6 | Flop.Schema,
7 | filterable: [:status, :destination, :from, :date_range, :subject],
8 | sortable: [:status, :inserted_at],
9 | max_limit: 100,
10 | default_limit: 12,
11 | default_order: %{
12 | order_by: [:inserted_at],
13 | order_directions: [:desc, :asc]
14 | },
15 | adapter_opts: [
16 | custom_fields: [
17 | date_range: [
18 | filter: {CustomFilters, :date_range, [source: :inserted_at, timezone: "-7 days"]},
19 | ecto_type: :string,
20 | operators: [:>=]
21 | ]
22 | ],
23 | join_fields: [
24 | destination: [
25 | binding: :recipient,
26 | field: :destination,
27 | ecto_type: :string
28 | ],
29 | from: [
30 | binding: :email,
31 | field: :from,
32 | ecto_type: :string
33 | ],
34 | subject: [
35 | binding: :email,
36 | field: :subject,
37 | ecto_type: :string
38 | ]
39 | ]
40 | ]
41 | }
42 |
43 | schema "messages" do
44 | field :status, Ecto.Enum, values: [:pending, :sent, :delivered, :bounced, :complained]
45 | has_many :events, Phoenix00.Events.Event
46 |
47 | belongs_to :email, Phoenix00.Messages.Email, foreign_key: :transmission
48 | belongs_to :recipient, Phoenix00.Contacts.Recipient, foreign_key: :recipient_id
49 |
50 | timestamps(type: :utc_datetime)
51 | end
52 |
53 | use Fsmx.Struct,
54 | state_field: :status,
55 | transitions: %{
56 | :pending => :*,
57 | :sent => [:complained, :bounced, :delivered],
58 | :* => [:bounced, :complained]
59 | }
60 |
61 | @doc false
62 | def changeset(message, attrs) do
63 | message
64 | |> cast(attrs, [:status, :recipient_id, :transmission])
65 | |> validate_required([:status, :recipient_id, :transmission])
66 | |> unique_constraint([:recipient_id, :transmission])
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/lib/phoenix_00/messages/message_repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Messages.MessageRepo do
2 | import Ecto.Query, warn: false
3 | alias Phoenix00.Events
4 | alias Phoenix00.Messages.Message
5 | alias Phoenix00.Repo
6 |
7 | def update_status_by_sender_id_and_destinations(transmission_id, recipients, status) do
8 | Enum.each(recipients, fn recipient ->
9 | Events.create_event_for_message(
10 | ensure_message_exists(transmission_id, recipient),
11 | status
12 | )
13 | end)
14 |
15 | updates =
16 | Repo.all(get_messages_by_sender_id_and_recipients_query(transmission_id, recipients))
17 | |> Enum.map(
18 | &Fsmx.transition_changeset(&1, String.to_existing_atom(status), %{}, state_field: :status)
19 | )
20 | |> Enum.filter(fn changeset -> changeset.valid? end)
21 |
22 | Repo.transaction(fn -> Enum.map(updates, &Repo.update(&1)) end)
23 | end
24 |
25 | defp ensure_message_exists(transmission_id, recipient) do
26 | query =
27 | from message in Message,
28 | where: message.transmission == ^transmission_id and message.recipient_id == ^recipient
29 |
30 | if !Repo.one(query) do
31 | create_message(%{
32 | status: :sent,
33 | recipient_id: recipient,
34 | transmission: transmission_id
35 | })
36 | end
37 |
38 | Repo.one(query)
39 | end
40 |
41 | def create_message(attrs \\ %{}) do
42 | %Message{}
43 | |> Message.changeset(attrs)
44 | |> Repo.insert()
45 | end
46 |
47 | defp get_messages_by_sender_id_and_recipients_query(transmission_id, recipients) do
48 | from message in Message,
49 | where: message.transmission == ^transmission_id and message.recipient_id in ^recipients
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/phoenix_00/messages/services/send_email.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Messages.Services.SendEmail do
2 | alias Phoenix00.MailMan
3 | require Logger
4 |
5 | def call(%{"from" => _, "to" => _, "subject" => _, "html" => _} = email_req),
6 | do: proccess_and_send_email(email_req)
7 |
8 | def call(%{"from" => _, "to" => _, "subject" => _, "markdown" => _} = email_req),
9 | do: proccess_and_send_email(email_req)
10 |
11 | def call(req) do
12 | Logger.error("Incorrect arguments SendEmail called with:")
13 | Logger.error(req)
14 | Logger.error("Expected %{to, from, subject, html} or %{to, from, subject, markdown}")
15 | :error
16 | end
17 |
18 | defp proccess_and_send_email(email_req) do
19 | with {:ok, email} <- MailMan.letter(email_req),
20 | {:ok, _job} <- MailMan.send_letter(email) do
21 | Logger.info("Successfully queued email to: #{email_req["to"]}")
22 | email
23 | else
24 | {:error, error} ->
25 | Logger.error("Failed to queue email to: #{email_req["to"]}")
26 | Logger.error(error)
27 | {:error, error}
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/phoenix_00/messages/status_machine.ex:
--------------------------------------------------------------------------------
1 | defmodule App.StatusMachine do
2 | defstruct [:state]
3 |
4 | use Fsmx.Struct,
5 | transitions: %{
6 | "pending" => :*,
7 | "sent" => ["complaint", "bounced", "delivered"],
8 | :* => ["bounced", "complaint"]
9 | }
10 |
11 | def get_status_from_event_type(event_type) do
12 | case event_type do
13 | "Bounce" -> "bounced"
14 | "Complaint" -> "complained"
15 | "Send" -> "sent"
16 | "Pending" -> "pending"
17 | "Delivery" -> "delivered"
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/phoenix_00/release.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Release do
2 | @moduledoc """
3 | Used for executing DB release tasks when run in production without Mix
4 | installed.
5 | """
6 | @app :phoenix_00
7 |
8 | def migrate do
9 | load_app()
10 |
11 | for repo <- repos() do
12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
13 | end
14 | end
15 |
16 | def rollback(repo, version) do
17 | load_app()
18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
19 | end
20 |
21 | defp repos do
22 | Application.fetch_env!(@app, :ecto_repos)
23 | end
24 |
25 | defp load_app do
26 | Application.load(@app)
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/phoenix_00/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Repo do
2 | use Ecto.Repo,
3 | otp_app: :phoenix_00,
4 | adapter: Ecto.Adapters.SQLite3
5 | end
6 |
--------------------------------------------------------------------------------
/lib/phoenix_00/uuid_schema.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.UUIDSchema do
2 | defmacro __using__(_) do
3 | quote do
4 | use Ecto.Schema
5 | @primary_key {:id, :binary_id, autogenerate: true}
6 | @foreign_key_type :binary_id
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use Phoenix00Web, :controller
9 | use Phoenix00Web, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router, helpers: false
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | import Phoenix.LiveView.Router
30 | end
31 | end
32 |
33 | def channel do
34 | quote do
35 | use Phoenix.Channel
36 | end
37 | end
38 |
39 | def controller do
40 | quote do
41 | use Phoenix.Controller,
42 | formats: [:html, :json],
43 | layouts: [html: Phoenix00Web.Layouts]
44 |
45 | import Plug.Conn
46 | import Phoenix00Web.Gettext
47 |
48 | unquote(verified_routes())
49 | end
50 | end
51 |
52 | def live_view do
53 | quote do
54 | use Phoenix.LiveView,
55 | layout: {Phoenix00Web.Layouts, :app},
56 | container: {:div, class: "h-full flex flex-col justify-stretch"}
57 |
58 | unquote(html_helpers())
59 | end
60 | end
61 |
62 | def live_component do
63 | quote do
64 | use Phoenix.LiveComponent
65 |
66 | unquote(html_helpers())
67 | end
68 | end
69 |
70 | def html do
71 | quote do
72 | use Phoenix.Component
73 |
74 | # Import convenience functions from controllers
75 | import Phoenix.Controller,
76 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
77 |
78 | # Include general helpers for rendering HTML
79 | unquote(html_helpers())
80 | end
81 | end
82 |
83 | defp html_helpers do
84 | quote do
85 | # HTML escaping functionality
86 | import Phoenix.HTML
87 | # Core UI components and translation
88 | import Phoenix00Web.CoreComponents
89 | import Phoenix00Web.Gettext
90 |
91 | # Shortcut for generating JS commands
92 | alias Phoenix.LiveView.JS
93 |
94 | # Routes generation with the ~p sigil
95 | unquote(verified_routes())
96 | end
97 | end
98 |
99 | def verified_routes do
100 | quote do
101 | use Phoenix.VerifiedRoutes,
102 | endpoint: Phoenix00Web.Endpoint,
103 | router: Phoenix00Web.Router,
104 | statics: Phoenix00Web.static_paths()
105 | end
106 | end
107 |
108 | @doc """
109 | When used, dispatch to the appropriate controller/live_view/etc.
110 | """
111 | defmacro __using__(which) when is_atom(which) do
112 | apply(__MODULE__, which, [])
113 | end
114 | end
115 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/components/flop_config.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.FlopConfig do
2 | def table_opts do
3 | [
4 | table_attrs: [class: "table table-sm table-zebra"],
5 | # thead_th_attrs: [class: "p-2 bg-gray-50 border border-slate-300"],
6 | tbody_td_attrs: [class: "cursor-pointer"]
7 | ]
8 | end
9 |
10 | def pagination_opts do
11 | [
12 | page_links: {:ellipsis, 5},
13 | disabled_class: "disabled opacity-50",
14 | wrapper_attrs: [
15 | class: "text-center mt-4 flex"
16 | ],
17 | previous_link_content: Phoenix.HTML.raw("← Previous"),
18 | previous_link_attrs: [
19 | class: "p-2 mr-2 border-2 btn border-base-300"
20 | ],
21 | pagination_list_attrs: [
22 | class: "flex order-2 place-items-center gap-4 px-4"
23 | ],
24 | next_link_content: Phoenix.HTML.raw("Next →"),
25 | next_link_attrs: [
26 | class: "p-2 ml-2 border-2 btn order-3 border-base-300"
27 | ],
28 | current_link_attrs: [
29 | class: "text-success"
30 | ]
31 | ]
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/components/layouts.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.Layouts do
2 | @moduledoc """
3 | This module holds different layouts used by your application.
4 |
5 | See the `layouts` directory for all templates available.
6 | The "root" layout is a skeleton rendered as part of the
7 | application router. The "app" layout is set as the default
8 | layout on both `use Phoenix00Web, :controller` and
9 | `use Phoenix00Web, :live_view`.
10 | """
11 | use Phoenix00Web, :html
12 |
13 | embed_templates "layouts/*"
14 | end
15 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/components/layouts/app.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
13 | <%= if @current_user do %>
14 |
15 | <%= @current_user.email %>
16 |
17 |
18 |
19 | <.link
20 | href={~p"/users/log_out"}
21 | method="delete"
22 | class="text-[0.8125rem] leading-6 font-semibold"
23 | >
24 | Log out
25 |
26 |
27 | <% else %>
28 |
29 | <.link href={~p"/users/register"} class="text-[0.8125rem] leading-6 font-semibold">
30 | Register
31 |
32 |
33 |
34 | <.link href={~p"/users/log_in"} class="text-[0.8125rem] leading-6 font-semibold">
35 | Log in
36 |
37 |
38 | <% end %>
39 |
40 |
41 |
42 |
43 |
44 |
66 |
72 |
73 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/components/layouts/root.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <.live_title suffix=" · Phoenix Framework">
8 | <%= assigns[:page_title] || "Phoenix00" %>
9 |
10 |
11 |
13 |
14 |
15 | <%= @inner_content %>
16 |
17 |
18 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/components/timeline.ex:
--------------------------------------------------------------------------------
1 | defmodule Timeline do
2 | alias Phoenix00Web.CoreComponents
3 | alias Timex
4 | # In Phoenix apps, the line is typically: use MyAppWeb, :html
5 | use Phoenix.Component
6 |
7 | def view(assigns) do
8 | ~H"""
9 |
10 |
11 |
12 |
13 | <%= Timex.format!(row.inserted_at, "%b %d, %H:%M%P", :strftime) %>
14 |
15 |
16 | "hero-check-circle text-success"
20 | "sent" -> "hero-paper-airplane"
21 | "bounced" -> "hero-exclamation-circle text-warning"
22 | "complained" -> "hero-exclamation-triangle text-error"
23 | end
24 | }
25 | class="h-5 w-5"
26 | />
27 |
28 |
29 |
33 | <%= row.status %>
34 |
35 |
36 |
37 |
38 |
39 | """
40 | end
41 |
42 | def compact(assigns) do
43 | ~H"""
44 |
45 |
46 | <%= @destination %>
47 |
48 |
67 |
68 | """
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/controllers/FallbackController.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.FallbackController do
2 | use Phoenix.Controller
3 |
4 | def call(conn, {:error, :not_found}) do
5 | conn
6 | |> put_status(:not_found)
7 | |> put_view(json: Phoenix00Web.ErrorJSON)
8 | |> render(:"404")
9 | end
10 |
11 | def call(conn, {:error, :unauthorized}) do
12 | conn
13 | |> put_status(403)
14 | |> put_view(json: Phoenix00Web.ErrorJSON)
15 | |> render(:"403")
16 | end
17 |
18 | def call(conn, _) do
19 | conn
20 | |> put_status(500)
21 | |> put_view(json: Phoenix00Web.ErrorJSON)
22 | |> render(:"500")
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/controllers/email_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.EmailController do
2 | alias Phoenix00.Logs
3 | alias Phoenix00.Messages
4 | use Phoenix00Web, :controller
5 |
6 | action_fallback Phoenix00.FallbackController
7 |
8 | def send(conn, email) do
9 | token = conn.assigns[:token]
10 |
11 | case Map.merge(email, %{"token_id" => token.id}) |> Messages.send_email() do
12 | {:error, reason} ->
13 | Logs.create_log(%{
14 | status: 500,
15 | source: "api:/api/emails",
16 | method: :post,
17 | request: email,
18 | response: %{error: reason},
19 | token_id: token.id
20 | # email_id: record["id"]
21 | })
22 |
23 | render(conn, :index, data: %{error: reason})
24 |
25 | record ->
26 | response = %{
27 | success: true,
28 | message: "Your email has successfully been queued.",
29 | id: record["id"]
30 | }
31 |
32 | Logs.create_log(%{
33 | status: 200,
34 | source: "api:/api/emails",
35 | method: :post,
36 | request: email,
37 | response: response,
38 | token_id: token.id,
39 | email_id: record["id"]
40 | })
41 |
42 | render(conn, :index, data: response)
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/controllers/email_json.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.EmailJSON do
2 | # alias Phoenix00.Logs
3 |
4 | def index(%{data: data}) do
5 | %{data: data}
6 | end
7 |
8 | # def send(%{email: email, token: token}) do
9 | # %{data: data(email, token)}
10 | # end
11 |
12 | # defp data(email, token) do
13 | # response = %{success: true, message: "Your email has successfully been queued."}
14 |
15 | # Logs.create_log(%{
16 | # status: 200,
17 | # endpoint: "/api/emails",
18 | # method: :post,
19 | # request: email,
20 | # response: response,
21 | # token_id: token.id
22 | # })
23 |
24 | # response
25 | # end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/controllers/error_html.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.ErrorHTML do
2 | @moduledoc """
3 | This module is invoked by your endpoint in case of errors on HTML requests.
4 |
5 | See config/config.exs.
6 | """
7 | use Phoenix00Web, :html
8 |
9 | # If you want to customize your error pages,
10 | # uncomment the embed_templates/1 call below
11 | # and add pages to the error directory:
12 | #
13 | # * lib/phoenix_00_web/controllers/error_html/404.html.heex
14 | # * lib/phoenix_00_web/controllers/error_html/500.html.heex
15 | #
16 | # embed_templates "error_html/*"
17 |
18 | # The default is to render a plain text page based on
19 | # the template name. For example, "404.html" becomes
20 | # "Not Found".
21 | def render(template, _assigns) do
22 | Phoenix.Controller.status_message_from_template(template)
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/controllers/error_json.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.ErrorJSON do
2 | @moduledoc """
3 | This module is invoked by your endpoint in case of errors on JSON requests.
4 |
5 | See config/config.exs.
6 | """
7 |
8 | # If you want to customize a particular status code,
9 | # you may add your own clauses, such as:
10 | #
11 | def render("500.json", _assigns) do
12 | %{errors: %{detail: "Internal Server Error"}}
13 | end
14 |
15 | # By default, Phoenix returns the status message from
16 | # the template name. For example, "404.json" becomes
17 | # "Not Found".
18 | def render(template, _assigns) do
19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.PageController do
2 | alias Phoenix00.Accounts
3 | use Phoenix00Web, :controller
4 |
5 | def home(conn, _params) do
6 | # The home page is often custom made,
7 | # so skip the default app layout.
8 | case Accounts.user_exists?() do
9 | true ->
10 | conn
11 | |> redirect(to: ~p"/users/log_in")
12 |
13 | false ->
14 | redirect(conn, to: ~p"/users/register")
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/controllers/page_html.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.PageHTML do
2 | @moduledoc """
3 | This module contains pages rendered by PageController.
4 |
5 | See the `page_html` directory for all templates available.
6 | """
7 | use Phoenix00Web, :html
8 |
9 | embed_templates "page_html/*"
10 | end
11 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/controllers/user_session_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserSessionController do
2 | use Phoenix00Web, :controller
3 |
4 | alias Phoenix00.Accounts
5 | alias Phoenix00Web.UserAuth
6 |
7 | def create(conn, %{"_action" => "registered"} = params) do
8 | create(conn, params, "Account created successfully!")
9 | end
10 |
11 | def create(conn, %{"_action" => "password_updated"} = params) do
12 | conn
13 | |> put_session(:user_return_to, ~p"/users/settings")
14 | |> create(params, "Password updated successfully!")
15 | end
16 |
17 | def create(conn, params) do
18 | create(conn, params, "Welcome back!")
19 | end
20 |
21 | defp create(conn, %{"user" => user_params}, info) do
22 | %{"email" => email, "password" => password} = user_params
23 |
24 | if user = Accounts.get_user_by_email_and_password(email, password) do
25 | conn
26 | |> put_flash(:info, info)
27 | |> UserAuth.log_in_user(user, user_params)
28 | else
29 | # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
30 | conn
31 | |> put_flash(:error, "Invalid email or password")
32 | |> put_flash(:email, String.slice(email, 0, 160))
33 | |> redirect(to: ~p"/users/log_in")
34 | end
35 | end
36 |
37 | def delete(conn, _params) do
38 | conn
39 | |> put_flash(:info, "Logged out successfully.")
40 | |> UserAuth.log_out_user()
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :phoenix_00
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_phoenix_00_key",
10 | signing_salt: "JKfXNNpB",
11 | same_site: "Lax"
12 | ]
13 |
14 | socket "/live", Phoenix.LiveView.Socket,
15 | websocket: [connect_info: [session: @session_options]],
16 | longpoll: [connect_info: [session: @session_options]]
17 |
18 | # Serve at "/" the static files from "priv/static" directory.
19 | #
20 | # You should set gzip to true if you are running phx.digest
21 | # when deploying your static files in production.
22 | plug Plug.Static,
23 | at: "/",
24 | from: :phoenix_00,
25 | gzip: false,
26 | only: Phoenix00Web.static_paths()
27 |
28 | # Code reloading can be explicitly enabled under the
29 | # :code_reloader configuration of your endpoint.
30 | if code_reloading? do
31 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
32 | plug Phoenix.LiveReloader
33 | plug Phoenix.CodeReloader
34 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :phoenix_00
35 | end
36 |
37 | plug Phoenix.LiveDashboard.RequestLogger,
38 | param_key: "request_logger",
39 | cookie_key: "request_logger"
40 |
41 | plug Plug.RequestId
42 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
43 |
44 | plug Plug.Parsers,
45 | parsers: [:urlencoded, :multipart, :json],
46 | pass: ["*/*"],
47 | json_decoder: Phoenix.json_library()
48 |
49 | plug Plug.MethodOverride
50 | plug Plug.Head
51 | plug Plug.Session, @session_options
52 | plug Phoenix00Web.Router
53 | end
54 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import Phoenix00Web.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :phoenix_00
24 | end
25 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/email_live/form_component.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.EmailLive.FormComponent do
2 | require Logger
3 | use Phoenix00Web, :live_component
4 |
5 | alias Phoenix00.Messages
6 |
7 | @impl true
8 | def render(assigns) do
9 | ~H"""
10 |
11 | <.header>
12 | <%= @title %>
13 | <:subtitle>Use this form to manage email records in your database.
14 |
15 |
16 | <.simple_form
17 | for={@form}
18 | id="email-form"
19 | phx-target={@myself}
20 | phx-change="validate"
21 | phx-submit="save"
22 | >
23 | <.input field={@form[:aws_message_id]} type="text" label="Aws message" />
24 | <.input field={@form[:to]} type="text" label="To" />
25 | <.input field={@form[:from]} type="text" label="From" />
26 | <.input
27 | field={@form[:status]}
28 | type="select"
29 | label="Status"
30 | prompt="Choose a value"
31 | options={Ecto.Enum.values(Phoenix00.Messages.Email, :status)}
32 | />
33 | <.input field={@form[:email_id]} type="text" label="Email" />
34 | <:actions>
35 | <.button phx-disable-with="Saving...">Save Email
36 |
37 |
38 |
39 | """
40 | end
41 |
42 | @impl true
43 | def update(%{email: email} = assigns, socket) do
44 | changeset = Messages.change_email(email)
45 |
46 | {:ok,
47 | socket
48 | |> assign(assigns)
49 | |> assign_form(changeset)}
50 | end
51 |
52 | @impl true
53 | def handle_event("validate", %{"email" => email_params}, socket) do
54 | changeset =
55 | socket.assigns.email
56 | |> Messages.change_email(email_params)
57 | |> Map.put(:action, :validate)
58 |
59 | {:noreply, assign_form(socket, changeset)}
60 | end
61 |
62 | def handle_event("save", %{"email" => email_params}, socket) do
63 | save_email(socket, socket.assigns.action, email_params)
64 | end
65 |
66 | defp save_email(socket, :edit, email_params) do
67 | case Messages.update_email(socket.assigns.email, email_params) do
68 | {:ok, email} ->
69 | notify_parent({:saved, email})
70 |
71 | {:noreply,
72 | socket
73 | |> put_flash(:info, "Email updated successfully")
74 | |> push_patch(to: socket.assigns.patch)}
75 |
76 | {:error, %Ecto.Changeset{} = changeset} ->
77 | {:noreply, assign_form(socket, changeset)}
78 | end
79 | end
80 |
81 | defp save_email(_socket, :new, _email_params) do
82 | Logger.warning("Create Email UI is not implemented.")
83 | # case Messages.create_email(email_params) do
84 | # {:ok, email} ->
85 | # notify_parent({:saved, email})
86 |
87 | # {:noreply,
88 | # socket
89 | # |> put_flash(:info, "Email created successfully")
90 | # |> push_patch(to: socket.assigns.patch)}
91 |
92 | # {:error, %Ecto.Changeset{} = changeset} ->
93 | # {:noreply, assign_form(socket, changeset)}
94 | # end
95 | end
96 |
97 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do
98 | assign(socket, :form, to_form(changeset))
99 | end
100 |
101 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
102 | end
103 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/email_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.EmailLive.Index do
2 | use Phoenix00Web, :live_view
3 |
4 | alias Phoenix00.Messages
5 | alias Phoenix00.Messages.Email
6 |
7 | @impl true
8 | def mount(_params, _session, socket) do
9 | {:ok, stream(socket, :emails, [], page: 0, max_page: 1)}
10 | end
11 |
12 | @impl true
13 | def handle_params(params, _url, socket) do
14 | {:noreply, apply_action(socket, socket.assigns.live_action, params)}
15 | end
16 |
17 | defp apply_action(socket, :edit, %{"id" => id}) do
18 | socket
19 | |> assign(:page_title, "Edit Email")
20 | |> assign(:email, Messages.get_email!(id))
21 | end
22 |
23 | defp apply_action(socket, :new, _params) do
24 | socket
25 | |> assign(:page_title, "New Email")
26 | |> assign(:email, %Email{})
27 | end
28 |
29 | defp apply_action(socket, :index, params) do
30 | case Messages.list_emails_flop(params) do
31 | {:ok, {emails, meta}} ->
32 | socket
33 | |> assign(:page_title, "Listing Emails")
34 | |> assign(:form, Phoenix.Component.to_form(meta))
35 | |> assign(:meta, meta)
36 | |> stream(
37 | :emails,
38 | emails,
39 | reset: true
40 | )
41 | end
42 | end
43 |
44 | @impl true
45 | def handle_info({Phoenix00Web.EmailLive.FormComponent, {:saved, email}}, socket) do
46 | {:noreply, stream_insert(socket, :emails, email)}
47 | end
48 |
49 | def handle_event("update_filter", params, socket) do
50 | params = Map.delete(params, "_target")
51 | {:noreply, push_patch(socket, to: ~p"/emails?#{params}")}
52 | end
53 |
54 | @impl true
55 | def handle_event("delete", %{"id" => id}, socket) do
56 | email = Messages.get_email!(id)
57 | {:ok, _} = Messages.delete_email(email)
58 |
59 | {:noreply, stream_delete(socket, :emails, email)}
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/email_live/show.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.EmailLive.Show do
2 | use Phoenix00Web, :live_view
3 |
4 | alias Phoenix00.Messages
5 |
6 | @impl true
7 | def mount(_params, _session, socket) do
8 | {:ok, socket}
9 | end
10 |
11 | @impl true
12 | def handle_params(%{"id" => id}, _, socket) do
13 | {:noreply,
14 | socket
15 | |> assign(:page_title, page_title(socket.assigns.live_action))
16 | |> assign(:email, Messages.get_email!(id))}
17 | end
18 |
19 | defp page_title(:show), do: "Show Email"
20 | defp page_title(:edit), do: "Edit Email"
21 |
22 | defp sort_events(events) do
23 | order = ["pending", "sent", "delivered", "bounced", "complained"]
24 | order_idx = order |> Enum.zip(1..5) |> Map.new()
25 | Enum.sort_by(events, &order_idx[&1.status])
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/email_live/show.html.heex:
--------------------------------------------------------------------------------
1 | <.back navigate={~p"/emails"}>Back to emails
2 | <.header>
3 | Email <%= @email.id %>
4 | <:subtitle>This is a email record from your database.
5 |
6 |
Status
7 |
8 | <.link
9 | :for={message <- @email.messages}
10 | href={~p"/messages/#{message.id}"}
11 | data-status={message.status}
12 | >
13 | <%!-- <%= message.recipient.destination %> --%>
14 |
18 |
19 |
20 | <.list>
21 | <:item title="From"><%= @email.from %>
22 | <:item title="To">
23 |
24 |
25 | <%= to %>
26 |
27 |
28 |
29 | <:item :if={length(List.wrap(@email.cc)) != 0} title="CC">
30 |
31 |
32 | <%= to %>
33 |
34 |
35 |
36 | <:item :if={length(List.wrap(@email.bcc)) != 0} title="BCC">
37 |
38 |
39 | <%= to %>
40 |
41 |
42 |
43 | <:item :if={length(List.wrap(@email.reply_to)) != 0} title="Reply To">
44 |
45 |
46 | <%= to %>
47 |
48 |
49 |
50 | <:item title="AWS ID"><%= @email.sender_id %>
51 | <:item title="Logs">
52 |
53 |
54 | <.link
55 | class={[
56 | "uppercase underline",
57 | log.status == 200 && "text-success",
58 | log.status == 400 && "text-warning",
59 | log.status == 500 && "text-error"
60 | ]}
61 | href={~p"/logs/#{log.id}"}
62 | >
63 | <%= log.status %>
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | <%= @email.subject %>
73 |
74 |
75 |
83 |
84 |
85 |
86 | <.modal
87 | :if={@live_action == :edit}
88 | id="email-modal"
89 | show
90 | on_cancel={JS.patch(~p"/emails/#{@email}")}
91 | >
92 | <.live_component
93 | module={Phoenix00Web.EmailLive.FormComponent}
94 | id={@email.id}
95 | title={@page_title}
96 | action={@live_action}
97 | email={@email}
98 | patch={~p"/emails/#{@email}"}
99 | />
100 |
101 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/log_live/form_component.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.LogLive.FormComponent do
2 | use Phoenix00Web, :live_component
3 |
4 | alias Phoenix00.Logs
5 |
6 | @impl true
7 | def render(assigns) do
8 | ~H"""
9 |
10 | <.header>
11 | <%= @title %>
12 | <:subtitle>Use this form to manage log records in your database.
13 |
14 |
15 | <.simple_form
16 | for={@form}
17 | id="log-form"
18 | phx-target={@myself}
19 | phx-change="validate"
20 | phx-submit="save"
21 | >
22 | <.input field={@form[:status]} type="number" label="Status" />
23 | <.input field={@form[:source]} type="text" label="Source" />
24 | <.input
25 | field={@form[:method]}
26 | type="select"
27 | label="Method"
28 | prompt="Choose a value"
29 | options={Ecto.Enum.values(Phoenix00.Logs.Log, :method)}
30 | />
31 | <.input field={@form[:ke_name]} type="text" label="Ke name" />
32 | <:actions>
33 | <.button phx-disable-with="Saving...">Save Log
34 |
35 |
36 |
37 | """
38 | end
39 |
40 | @impl true
41 | def update(%{log: log} = assigns, socket) do
42 | changeset = Logs.change_log(log)
43 |
44 | {:ok,
45 | socket
46 | |> assign(assigns)
47 | |> assign_form(changeset)}
48 | end
49 |
50 | @impl true
51 | def handle_event("validate", %{"log" => log_params}, socket) do
52 | changeset =
53 | socket.assigns.log
54 | |> Logs.change_log(log_params)
55 | |> Map.put(:action, :validate)
56 |
57 | {:noreply, assign_form(socket, changeset)}
58 | end
59 |
60 | def handle_event("save", %{"log" => log_params}, socket) do
61 | save_log(socket, socket.assigns.action, log_params)
62 | end
63 |
64 | defp save_log(socket, :edit, log_params) do
65 | case Logs.update_log(socket.assigns.log, log_params) do
66 | {:ok, log} ->
67 | notify_parent({:saved, log})
68 |
69 | {:noreply,
70 | socket
71 | |> put_flash(:info, "Log updated successfully")
72 | |> push_patch(to: socket.assigns.patch)}
73 |
74 | {:error, %Ecto.Changeset{} = changeset} ->
75 | {:noreply, assign_form(socket, changeset)}
76 | end
77 | end
78 |
79 | defp save_log(socket, :new, log_params) do
80 | case Logs.create_log(log_params) do
81 | {:ok, log} ->
82 | notify_parent({:saved, log})
83 |
84 | {:noreply,
85 | socket
86 | |> put_flash(:info, "Log created successfully")
87 | |> push_patch(to: socket.assigns.patch)}
88 |
89 | {:error, %Ecto.Changeset{} = changeset} ->
90 | {:noreply, assign_form(socket, changeset)}
91 | end
92 | end
93 |
94 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do
95 | assign(socket, :form, to_form(changeset))
96 | end
97 |
98 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
99 | end
100 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/log_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.LogLive.Index do
2 | alias Phoenix00.Accounts
3 | use Phoenix00Web, :live_view
4 |
5 | alias Phoenix00.Logs
6 | alias Phoenix00.Logs.Log
7 |
8 | @impl true
9 | def mount(_params, _session, socket) do
10 | {:ok, stream(socket, :logs, [])}
11 | end
12 |
13 | @impl true
14 | def handle_params(params, _url, socket) do
15 | {:noreply, apply_action(socket, socket.assigns.live_action, params)}
16 | end
17 |
18 | defp apply_action(socket, :edit, %{"id" => id}) do
19 | socket
20 | |> assign(:page_title, "Edit Log")
21 | |> assign(:log, Logs.get_log!(id))
22 | end
23 |
24 | defp apply_action(socket, :new, _params) do
25 | socket
26 | |> assign(:page_title, "New Log")
27 | |> assign(:log, %Log{})
28 | end
29 |
30 | defp apply_action(socket, :index, params) do
31 | user = socket.assigns.current_user
32 |
33 | user_api_keys =
34 | Enum.map(Accounts.fetch_user_api_tokens(user), fn token -> {token.name, token.id} end)
35 |
36 | case Logs.list_logs_flop(params) do
37 | {:ok, {logs, meta}} ->
38 | socket
39 | |> assign(:page_title, "Listing Logs")
40 | |> assign(:form, Phoenix.Component.to_form(meta))
41 | |> assign(:tokens, user_api_keys)
42 | |> stream(
43 | :logs,
44 | logs,
45 | reset: true
46 | )
47 | |> assign(:meta, meta)
48 | |> assign(:log, nil)
49 | end
50 | end
51 |
52 | @impl true
53 | def handle_info({Phoenix00Web.LogLive.FormComponent, {:saved, log}}, socket) do
54 | {:noreply, stream_insert(socket, :logs, log)}
55 | end
56 |
57 | @impl true
58 | def handle_event("delete", %{"id" => id}, socket) do
59 | log = Logs.get_log!(id)
60 | {:ok, _} = Logs.delete_log(log)
61 |
62 | {:noreply, stream_delete(socket, :logs, log)}
63 | end
64 |
65 | def handle_event("update_filter", params, socket) do
66 | params = Map.delete(params, "_target")
67 | {:noreply, push_patch(socket, to: ~p"/logs?#{params}")}
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/log_live/show.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.LogLive.Show do
2 | use Phoenix00Web, :live_view
3 |
4 | alias Phoenix00.Logs
5 |
6 | @impl true
7 | def mount(_params, _session, socket) do
8 | {:ok, socket}
9 | end
10 |
11 | @impl true
12 | def handle_params(%{"id" => id}, _, socket) do
13 | {
14 | :noreply,
15 | socket
16 | |> assign(:page_title, page_title(socket.assigns.live_action))
17 | |> assign(:log, Logs.get_log!(id))
18 | }
19 | end
20 |
21 | defp page_title(:show), do: "Show Log"
22 | defp page_title(:edit), do: "Edit Log"
23 | end
24 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/log_live/show.html.heex:
--------------------------------------------------------------------------------
1 | <.header>
2 | <.back navigate={~p"/logs"}>Back to logs
3 | Log <%= @log.id %>
4 | <:subtitle>This is a log record from your database.
5 | <%!-- <:actions>
6 | <.link patch={~p"/logs/#{@log}/show/edit"} phx-click={JS.push_focus()}>
7 | <.button>Edit log
8 |
9 | --%>
10 |
11 |
12 | <.list>
13 | <:item title="Status">
14 |
20 | <%= @log.status %>
21 |
22 |
23 | <:item :if={@log.email} title="Email">
24 | <.link class="text-success underline" href={~p"/emails/#{@log.email.id}"}>
25 | <%= @log.email.id %>
26 |
27 |
28 | <:item title="Source">
29 | <%= @log.source %>
30 |
31 | <:item title="Token name"><%= @log.token_id.name %>
32 | <:item title="Method"><%= @log.method %>
33 | <:item title="Response">
34 | <%= raw(
35 | Autumn.highlight!(Jason.Formatter.pretty_print(Jason.encode!(@log.response)),
36 | language: "json",
37 | pre_class: "p-4 w-full",
38 | theme: "dark_high_contrast"
39 | )
40 | ) %>
41 |
42 | <:item title="Request">
43 | <%= raw(
44 | Autumn.highlight!(Jason.Formatter.pretty_print(Jason.encode!(@log.request)),
45 | language: "json",
46 | pre_class: "p-4",
47 | theme: "dark_high_contrast"
48 | )
49 | ) %>
50 |
51 |
52 |
53 | <%!--
--%>
54 |
55 | <.modal :if={@live_action == :edit} id="log-modal" show on_cancel={JS.patch(~p"/logs/#{@log}")}>
56 | <.live_component
57 | module={Phoenix00Web.LogLive.FormComponent}
58 | id={@log.id}
59 | title={@page_title}
60 | action={@live_action}
61 | log={@log}
62 | patch={~p"/logs/#{@log}"}
63 | />
64 |
65 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/message_live/form_component.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.MessageLive.FormComponent do
2 | use Phoenix00Web, :live_component
3 |
4 | alias Phoenix00.Messages
5 |
6 | @impl true
7 | def render(assigns) do
8 | ~H"""
9 |
10 | <.header>
11 | <%= @title %>
12 | <:subtitle>Use this form to manage message records in your database.
13 |
14 |
15 | <.simple_form
16 | for={@form}
17 | id="message-form"
18 | phx-target={@myself}
19 | phx-change="validate"
20 | phx-submit="save"
21 | >
22 | <.input
23 | field={@form[:status]}
24 | type="select"
25 | label="Status"
26 | prompt="Choose a value"
27 | options={Ecto.Enum.values(Phoenix00.Messages.Message, :status)}
28 | />
29 | <:actions>
30 | <.button phx-disable-with="Saving...">Save Message
31 |
32 |
33 |
34 | """
35 | end
36 |
37 | @impl true
38 | def update(%{message: message} = assigns, socket) do
39 | changeset = Messages.change_message(message)
40 |
41 | {:ok,
42 | socket
43 | |> assign(assigns)
44 | |> assign_form(changeset)}
45 | end
46 |
47 | @impl true
48 | def handle_event("validate", %{"message" => message_params}, socket) do
49 | changeset =
50 | socket.assigns.message
51 | |> Messages.change_message(message_params)
52 | |> Map.put(:action, :validate)
53 |
54 | {:noreply, assign_form(socket, changeset)}
55 | end
56 |
57 | def handle_event("save", %{"message" => message_params}, socket) do
58 | save_message(socket, socket.assigns.action, message_params)
59 | end
60 |
61 | defp save_message(socket, :edit, message_params) do
62 | case Messages.update_message(socket.assigns.message, message_params) do
63 | {:ok, message} ->
64 | notify_parent({:saved, message})
65 |
66 | {:noreply,
67 | socket
68 | |> put_flash(:info, "Message updated successfully")
69 | |> push_patch(to: socket.assigns.patch)}
70 |
71 | {:error, %Ecto.Changeset{} = changeset} ->
72 | {:noreply, assign_form(socket, changeset)}
73 | end
74 | end
75 |
76 | defp save_message(socket, :new, message_params) do
77 | case Messages.create_message(message_params) do
78 | {:ok, message} ->
79 | notify_parent({:saved, message})
80 |
81 | {:noreply,
82 | socket
83 | |> put_flash(:info, "Message created successfully")
84 | |> push_patch(to: socket.assigns.patch)}
85 |
86 | {:error, %Ecto.Changeset{} = changeset} ->
87 | {:noreply, assign_form(socket, changeset)}
88 | end
89 | end
90 |
91 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do
92 | assign(socket, :form, to_form(changeset))
93 | end
94 |
95 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
96 | end
97 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/message_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.MessageLive.Index do
2 | use Phoenix00Web, :live_view
3 |
4 | alias Phoenix00.Messages
5 | alias Phoenix00.Messages.Message
6 |
7 | @impl true
8 | def mount(_params, _session, socket) do
9 | {:ok, stream(socket, :messages, [])}
10 | end
11 |
12 | @impl true
13 | def handle_params(params, _url, socket) do
14 | {:noreply, apply_action(socket, socket.assigns.live_action, params)}
15 | end
16 |
17 | defp apply_action(socket, :edit, %{"id" => id}) do
18 | socket
19 | |> assign(:page_title, "Edit Message")
20 | |> assign(:message, Messages.get_message!(id))
21 | end
22 |
23 | defp apply_action(socket, :new, _params) do
24 | socket
25 | |> assign(:page_title, "New Message")
26 | |> assign(:message, %Message{})
27 | end
28 |
29 | defp apply_action(socket, :index, params) do
30 | case Messages.list_messages_flop(params) do
31 | {:ok, {messages, meta}} ->
32 | socket
33 | |> assign(:page_title, "Listing Messages")
34 | |> stream(
35 | :messages,
36 | messages,
37 | reset: true
38 | )
39 | |> assign(:form, Phoenix.Component.to_form(meta))
40 | |> assign(:meta, meta)
41 | |> assign(:message, nil)
42 |
43 | {:error, _meta} ->
44 | # This will reset invalid parameters. Alternatively, you can assign
45 | # only the meta and render the errors, or you can ignore the error
46 | # case entirely.
47 | push_navigate(socket, to: ~p"/messages")
48 | end
49 | end
50 |
51 | @impl true
52 | def handle_info({Phoenix00Web.MessageLive.FormComponent, {:saved, message}}, socket) do
53 | {:noreply, stream_insert(socket, :messages, message)}
54 | end
55 |
56 | def handle_event("update_filter", params, socket) do
57 | params = Map.delete(params, "_target")
58 | {:noreply, push_patch(socket, to: ~p"/messages?#{params}")}
59 | end
60 |
61 | @impl true
62 | def handle_event("delete", %{"id" => id}, socket) do
63 | message = Messages.get_message!(id)
64 | {:ok, _} = Messages.delete_message(message)
65 |
66 | {:noreply, stream_delete(socket, :messages, message)}
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/message_live/index.html.heex:
--------------------------------------------------------------------------------
1 | <.header>
2 | Messages
3 | <:subtitle>View the status of messages sent to recipients.
4 | <:actions>
5 | <%!-- <.link patch={~p"/messages/new"}>
6 | <.button>New Message
7 | --%>
8 |
9 |
14 |
15 |
16 |
17 |
18 |
19 | <.form for={@form} class="flex gap-2" phx-change="update_filter" phx-submit="update_filter">
20 | =,
54 | type: "select",
55 | options: [
56 | {"All", ""},
57 | {"7 Days", "+7 days"},
58 | {"15 Days", "+15 days"},
59 | {"30 Days", "+30 days"}
60 | ]
61 | ]
62 | ]}
63 | >
64 | <.input field={i.field} label={i.label} type={i.type} {i.rest} />
65 |
66 |
67 |
68 |
69 |
70 |
JS.navigate(~p"/messages/#{message.id}") end}
76 | >
77 | <%!-- <:col :let={pet} label="Name" field={:name}><%= pet.name %> --%>
78 | <%!-- <:col :let={pet} label="Age" field={:age}><%= pet.age %> --%>
79 |
80 | <:col :let={{_id, message}} field={:status} label="Status">
81 |
90 | <%= message.status %>
91 |
92 |
93 |
94 | <:col :let={{_id, message}} label="Destination" field={:destination}>
95 | <%= message.recipient.destination %>
96 |
97 | <:col :let={{_id, message}} label="From" field={:from}><%= message.email.from %>
98 |
99 | <:col :let={{_id, message}} label="Subject" field={:subject}>
100 |
101 | <%= message.email.subject %>
102 |
103 |
104 |
105 | <:col :let={{_id, message}} label="Sent" field={:inserted_at}>
106 | <%= Timex.Format.DateTime.Formatters.Relative.format!(
107 | message.inserted_at,
108 | "{relative}"
109 | ) %>
110 |
111 | <%!-- <:col :let={{_id, message}} label="Email ID"><%= message.transmission.id %> --%>
112 |
113 |
114 | <.modal
115 | :if={@live_action in [:new, :edit]}
116 | id="message-modal"
117 | show
118 | on_cancel={JS.patch(~p"/messages")}
119 | >
120 | <.live_component
121 | module={Phoenix00Web.MessageLive.FormComponent}
122 | id={@message.id || :new}
123 | title={@page_title}
124 | action={@live_action}
125 | message={@message}
126 | patch={~p"/messages"}
127 | />
128 |
129 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/message_live/show.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.MessageLive.Show do
2 | use Phoenix00Web, :live_view
3 |
4 | alias Phoenix00.Messages
5 |
6 | @impl true
7 | def mount(_params, _session, socket) do
8 | {:ok, socket}
9 | end
10 |
11 | @impl true
12 | def handle_params(%{"id" => id}, _, socket) do
13 | {:noreply,
14 | socket
15 | |> assign(:page_title, page_title(socket.assigns.live_action))
16 | |> assign(:message, Messages.get_message!(id))}
17 | end
18 |
19 | def sort_events(events) do
20 | order = ["pending", "sent", "delivered", "bounced", "complained"]
21 | order_idx = order |> Enum.zip(1..5) |> Map.new()
22 | Enum.sort_by(events, &order_idx[&1.status])
23 | end
24 |
25 | defp page_title(:show), do: "Show Message"
26 | defp page_title(:edit), do: "Edit Message"
27 | end
28 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/message_live/show.html.heex:
--------------------------------------------------------------------------------
1 | <.header>
2 | <.back navigate={~p"/messages"}>Back to messages
3 | Message <%= @message.id %>
4 | <:subtitle>This is a message record from your database.
5 |
6 |
7 |
8 |
9 | <.list>
10 | <:item title="Status">
11 |
15 | <%= @message.status %>
16 |
17 |
18 | <:item title="Sent">
19 | <%= Timex.Format.DateTime.Formatters.Relative.format!(
20 | @message.inserted_at,
21 | "{relative}"
22 | ) %>
23 |
24 | <:item title="Email">
25 | <.link class="text-success underline" href={~p"/emails/#{@message.email.id}"}>
26 | <%= @message.email.id %>
27 |
28 |
29 | <:item title="Recipient"><%= @message.recipient.destination %>
30 | <:item title="From"><%= @message.email.from %>
31 | <%!-- <:item title="From"><%= @message.transmission.from %> --%>
32 |
33 |
34 |
35 |
36 | <%= @message.email.subject %>
37 |
38 |
39 |
47 |
48 |
49 |
50 | <.modal
51 | :if={@live_action == :edit}
52 | id="message-modal"
53 | show
54 | on_cancel={JS.patch(~p"/messages/#{@message}")}
55 | >
56 | <.live_component
57 | module={Phoenix00Web.MessageLive.FormComponent}
58 | id={@message.id}
59 | title={@page_title}
60 | action={@live_action}
61 | message={@message}
62 | patch={~p"/messages/#{@message}"}
63 | />
64 |
65 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/user_confirmation_instructions_live.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserConfirmationInstructionsLive do
2 | use Phoenix00Web, :live_view
3 |
4 | alias Phoenix00.Accounts
5 |
6 | def render(assigns) do
7 | ~H"""
8 |
9 | <.header class="text-center">
10 | No confirmation instructions received?
11 | <:subtitle>We'll send a new confirmation link to your inbox
12 |
13 |
14 | <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions">
15 | <.input field={@form[:email]} type="email" placeholder="Email" required />
16 | <:actions>
17 | <.button phx-disable-with="Sending..." class="w-full">
18 | Resend confirmation instructions
19 |
20 |
21 |
22 |
23 |
24 | <.link href={~p"/users/register"}>Register
25 | | <.link href={~p"/users/log_in"}>Log in
26 |
27 |
28 | """
29 | end
30 |
31 | def mount(_params, _session, socket) do
32 | {:ok, assign(socket, form: to_form(%{}, as: "user"))}
33 | end
34 |
35 | def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do
36 | if user = Accounts.get_user_by_email(email) do
37 | Accounts.deliver_user_confirmation_instructions(
38 | user,
39 | &url(~p"/users/confirm/#{&1}")
40 | )
41 | end
42 |
43 | info =
44 | "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
45 |
46 | {:noreply,
47 | socket
48 | |> put_flash(:info, info)
49 | |> redirect(to: ~p"/")}
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/user_confirmation_live.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserConfirmationLive do
2 | use Phoenix00Web, :live_view
3 |
4 | alias Phoenix00.Accounts
5 |
6 | def render(%{live_action: :edit} = assigns) do
7 | ~H"""
8 |
9 | <.header class="text-center">Confirm Account
10 |
11 | <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
12 |
13 | <:actions>
14 | <.button phx-disable-with="Confirming..." class="w-full">Confirm my account
15 |
16 |
17 |
18 |
19 | <.link href={~p"/users/register"}>Register
20 | | <.link href={~p"/users/log_in"}>Log in
21 |
22 |
23 | """
24 | end
25 |
26 | def mount(%{"token" => token}, _session, socket) do
27 | form = to_form(%{"token" => token}, as: "user")
28 | {:ok, assign(socket, form: form), temporary_assigns: [form: nil]}
29 | end
30 |
31 | # Do not log in the user after confirmation to avoid a
32 | # leaked token giving the user access to the account.
33 | def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do
34 | case Accounts.confirm_user(token) do
35 | {:ok, _} ->
36 | {:noreply,
37 | socket
38 | |> put_flash(:info, "User confirmed successfully.")
39 | |> redirect(to: ~p"/")}
40 |
41 | :error ->
42 | # If there is a current user and the account was already confirmed,
43 | # then odds are that the confirmation link was already visited, either
44 | # by some automation or by the user themselves, so we redirect without
45 | # a warning message.
46 | case socket.assigns do
47 | %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
48 | {:noreply, redirect(socket, to: ~p"/")}
49 |
50 | %{} ->
51 | {:noreply,
52 | socket
53 | |> put_flash(:error, "User confirmation link is invalid or it has expired.")
54 | |> redirect(to: ~p"/")}
55 | end
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/user_forgot_password_live.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserForgotPasswordLive do
2 | use Phoenix00Web, :live_view
3 |
4 | alias Phoenix00.Accounts
5 |
6 | def render(assigns) do
7 | ~H"""
8 |
9 | <.header class="text-center">
10 | Forgot your password?
11 | <:subtitle>We'll send a password reset link to your inbox
12 |
13 |
14 | <.simple_form for={@form} id="reset_password_form" phx-submit="send_email">
15 | <.input field={@form[:email]} type="email" placeholder="Email" required />
16 | <:actions>
17 | <.button phx-disable-with="Sending..." class="w-full">
18 | Send password reset instructions
19 |
20 |
21 |
22 |
23 | <.link href={~p"/users/register"}>Register
24 | | <.link href={~p"/users/log_in"}>Log in
25 |
26 |
27 | """
28 | end
29 |
30 | def mount(_params, _session, socket) do
31 | {:ok, assign(socket, form: to_form(%{}, as: "user"))}
32 | end
33 |
34 | def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
35 | if user = Accounts.get_user_by_email(email) do
36 | Accounts.deliver_user_reset_password_instructions(
37 | user,
38 | &url(~p"/users/reset_password/#{&1}")
39 | )
40 | end
41 |
42 | info =
43 | "If your email is in our system, you will receive instructions to reset your password shortly."
44 |
45 | {:noreply,
46 | socket
47 | |> put_flash(:info, info)
48 | |> redirect(to: ~p"/")}
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/user_login_live.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserLoginLive do
2 | alias Phoenix00.Accounts
3 | use Phoenix00Web, :live_view
4 |
5 | def render(assigns) do
6 | ~H"""
7 |
8 |
9 | <.header class="text-center">
10 | Log in to your
single
11 | account
12 | <:subtitle>
13 | If you need more than one account please
14 |
18 | upgrade to pro.
19 |
20 |
21 |
22 |
23 | <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">
24 | <.input field={@form[:email]} type="email" label="Email" required />
25 | <.input field={@form[:password]} type="password" label="Password" required />
26 |
27 | <:actions>
28 | <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
29 | <.link href={~p"/users/reset_password"} class="text-sm font-semibold">
30 | Forgot your password?
31 |
32 |
33 | <:actions>
34 | <.button phx-disable-with="Logging in..." class="w-full">
35 | Log in
→
36 |
37 |
38 |
39 |
40 |
41 | """
42 | end
43 |
44 | def mount(_params, _session, socket) do
45 | case Accounts.user_exists?() do
46 | true ->
47 | email = Phoenix.Flash.get(socket.assigns.flash, :email)
48 | form = to_form(%{"email" => email}, as: "user")
49 | {:ok, assign(socket, form: form), temporary_assigns: [form: form]}
50 |
51 | false ->
52 | {:ok, push_redirect(socket, to: ~p"/users/register")}
53 | end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/user_registration_live.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserRegistrationLive do
2 | use Phoenix00Web, :live_view
3 |
4 | alias Phoenix00.Accounts
5 | alias Phoenix00.Accounts.User
6 |
7 | def render(assigns) do
8 | ~H"""
9 |
10 |
11 | <.header class="text-center">
12 | Register your
single
13 | account.
14 | <:subtitle>
15 | If you need more than one account please
16 |
20 | upgrade to pro.
21 |
22 |
23 | <%!-- <:subtitle>
24 | Already registered?
25 | <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline">
26 | Log in
27 |
28 | to your account now.
29 | --%>
30 |
31 |
32 | <.simple_form
33 | for={@form}
34 | id="registration_form"
35 | phx-submit="save"
36 | phx-change="validate"
37 | phx-trigger-action={@trigger_submit}
38 | action={~p"/users/log_in?_action=registered"}
39 | method="post"
40 | >
41 | <.error :if={@check_errors}>
42 | Oops, something went wrong! Please check the errors below.
43 |
44 |
45 | <.input field={@form[:email]} type="email" label="Email" required />
46 | <.input field={@form[:password]} type="password" label="Password" required />
47 |
48 | <:actions>
49 | <.button phx-disable-with="Creating account..." class="w-full">Create an account
50 |
51 |
52 |
53 |
54 | """
55 | end
56 |
57 | def mount(_params, _session, socket) do
58 | changeset = Accounts.change_user_registration(%User{})
59 |
60 | case Accounts.user_exists?() do
61 | true ->
62 | {:ok, push_redirect(socket, to: ~p"/users/log_in")}
63 |
64 | false ->
65 | socket =
66 | socket
67 | |> assign(trigger_submit: false, check_errors: false)
68 | |> assign_form(changeset)
69 |
70 | {:ok, socket, temporary_assigns: [form: nil]}
71 | end
72 | end
73 |
74 | def handle_event("save", %{"user" => user_params}, socket) do
75 | case Accounts.register_user(user_params) do
76 | {:ok, user} ->
77 | {:ok, _} =
78 | Accounts.deliver_user_confirmation_instructions(
79 | user,
80 | &url(~p"/users/confirm/#{&1}")
81 | )
82 |
83 | changeset = Accounts.change_user_registration(user)
84 | {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
85 |
86 | {:error, %Ecto.Changeset{} = changeset} ->
87 | {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
88 | end
89 | end
90 |
91 | def handle_event("validate", %{"user" => user_params}, socket) do
92 | changeset = Accounts.change_user_registration(%User{}, user_params)
93 | {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
94 | end
95 |
96 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do
97 | form = to_form(changeset, as: "user")
98 |
99 | if changeset.valid? do
100 | assign(socket, form: form, check_errors: false)
101 | else
102 | assign(socket, form: form)
103 | end
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/live/user_reset_password_live.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserResetPasswordLive do
2 | use Phoenix00Web, :live_view
3 |
4 | alias Phoenix00.Accounts
5 |
6 | def render(assigns) do
7 | ~H"""
8 |
9 | <.header class="text-center">Reset Password
10 |
11 | <.simple_form
12 | for={@form}
13 | id="reset_password_form"
14 | phx-submit="reset_password"
15 | phx-change="validate"
16 | >
17 | <.error :if={@form.errors != []}>
18 | Oops, something went wrong! Please check the errors below.
19 |
20 |
21 | <.input field={@form[:password]} type="password" label="New password" required />
22 | <.input
23 | field={@form[:password_confirmation]}
24 | type="password"
25 | label="Confirm new password"
26 | required
27 | />
28 | <:actions>
29 | <.button phx-disable-with="Resetting..." class="w-full">Reset Password
30 |
31 |
32 |
33 |
34 | <.link href={~p"/users/register"}>Register
35 | | <.link href={~p"/users/log_in"}>Log in
36 |
37 |
38 | """
39 | end
40 |
41 | def mount(params, _session, socket) do
42 | socket = assign_user_and_token(socket, params)
43 |
44 | form_source =
45 | case socket.assigns do
46 | %{user: user} ->
47 | Accounts.change_user_password(user)
48 |
49 | _ ->
50 | %{}
51 | end
52 |
53 | {:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]}
54 | end
55 |
56 | # Do not log in the user after reset password to avoid a
57 | # leaked token giving the user access to the account.
58 | def handle_event("reset_password", %{"user" => user_params}, socket) do
59 | case Accounts.reset_user_password(socket.assigns.user, user_params) do
60 | {:ok, _} ->
61 | {:noreply,
62 | socket
63 | |> put_flash(:info, "Password reset successfully.")
64 | |> redirect(to: ~p"/users/log_in")}
65 |
66 | {:error, changeset} ->
67 | {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
68 | end
69 | end
70 |
71 | def handle_event("validate", %{"user" => user_params}, socket) do
72 | changeset = Accounts.change_user_password(socket.assigns.user, user_params)
73 | {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
74 | end
75 |
76 | defp assign_user_and_token(socket, %{"token" => token}) do
77 | if user = Accounts.get_user_by_reset_password_token(token) do
78 | assign(socket, user: user, token: token)
79 | else
80 | socket
81 | |> put_flash(:error, "Reset password link is invalid or it has expired.")
82 | |> redirect(to: ~p"/")
83 | end
84 | end
85 |
86 | defp assign_form(socket, %{} = source) do
87 | assign(socket, :form, to_form(source, as: "user"))
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.Router do
2 | use Phoenix00Web, :router
3 |
4 | import Phoenix00Web.UserAuth
5 |
6 | pipeline :browser do
7 | plug :accepts, ["html"]
8 | plug :fetch_session
9 | plug :fetch_live_flash
10 | plug :put_root_layout, html: {Phoenix00Web.Layouts, :root}
11 | plug :protect_from_forgery
12 | plug :put_secure_browser_headers
13 | plug :fetch_current_user
14 | end
15 |
16 | pipeline :api do
17 | plug :accepts, ["json"]
18 | plug :fetch_api_user
19 | end
20 |
21 | pipeline :aws do
22 | plug :accepts, ["json"]
23 | end
24 |
25 | scope "/", Phoenix00Web do
26 | pipe_through :browser
27 |
28 | get "/", PageController, :home
29 | end
30 |
31 | # Other scopes may use custom stacks.
32 | # scope "/api", Phoenix00Web do
33 | # pipe_through :api
34 | # end
35 |
36 | # Enable LiveDashboard and Swoosh mailbox preview in development
37 | if Application.compile_env(:phoenix_00, :dev_routes) do
38 | # If you want to use the LiveDashboard in production, you should put
39 | # it behind authentication and allow only admins to access it.
40 | # If your application does not have an admins-only section yet,
41 | # you can use Plug.BasicAuth to set up some basic authentication
42 | # as long as you are also using SSL (which you should anyway).
43 | import Phoenix.LiveDashboard.Router
44 |
45 | scope "/dev" do
46 | pipe_through :browser
47 |
48 | live_dashboard "/dashboard", metrics: Phoenix00Web.Telemetry
49 | forward "/mailbox", Plug.Swoosh.MailboxPreview
50 | end
51 | end
52 |
53 | ## Authentication routes
54 |
55 | scope "/", Phoenix00Web do
56 | pipe_through [:browser, :redirect_if_user_is_authenticated]
57 |
58 | live_session :redirect_if_user_is_authenticated,
59 | on_mount: [{Phoenix00Web.UserAuth, :redirect_if_user_is_authenticated}] do
60 | live "/users/register", UserRegistrationLive, :new
61 | live "/users/log_in", UserLoginLive, :new
62 | live "/users/reset_password", UserForgotPasswordLive, :new
63 | live "/users/reset_password/:token", UserResetPasswordLive, :edit
64 | end
65 |
66 | post "/users/log_in", UserSessionController, :create
67 | end
68 |
69 | scope "/", Phoenix00Web do
70 | pipe_through [:browser, :require_authenticated_user]
71 |
72 | live_session :require_authenticated_user,
73 | on_mount: [{Phoenix00Web.UserAuth, :ensure_authenticated}] do
74 | live "/users/settings", UserSettingsLive, :edit
75 | live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
76 |
77 | live "/emails", EmailLive.Index, :index
78 | # live "/emails/new", EmailLive.Index, :new
79 | # live "/emails/:id/edit", EmailLive.Index, :edit
80 |
81 | live "/emails/:id", EmailLive.Show, :show
82 | # live "/emails/:id/show/edit", EmailLive.Show, :edit
83 |
84 | live "/messages", MessageLive.Index, :index
85 | live "/messages/new", MessageLive.Index, :new
86 | live "/messages/:id/edit", MessageLive.Index, :edit
87 |
88 | live "/messages/:id", MessageLive.Show, :show
89 | live "/messages/:id/show/edit", MessageLive.Show, :edit
90 |
91 | live "/logs", LogLive.Index, :index
92 | live "/logs/new", LogLive.Index, :new
93 | live "/logs/:id/edit", LogLive.Index, :edit
94 |
95 | live "/logs/:id", LogLive.Show, :show
96 | live "/logs/:id/show/edit", LogLive.Show, :edit
97 | end
98 | end
99 |
100 | scope "/", Phoenix00Web do
101 | pipe_through [:browser]
102 |
103 | delete "/users/log_out", UserSessionController, :delete
104 |
105 | live_session :current_user,
106 | on_mount: [{Phoenix00Web.UserAuth, :mount_current_user}] do
107 | live "/users/confirm/:token", UserConfirmationLive, :edit
108 | live "/users/confirm", UserConfirmationInstructionsLive, :new
109 | end
110 | end
111 |
112 | scope "/api", Phoenix00Web do
113 | pipe_through [:api]
114 |
115 | post "/emails", EmailController, :send
116 | end
117 |
118 | end
119 |
--------------------------------------------------------------------------------
/lib/phoenix_00_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.start.system_time",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.endpoint.stop.duration",
29 | unit: {:native, :millisecond}
30 | ),
31 | summary("phoenix.router_dispatch.start.system_time",
32 | tags: [:route],
33 | unit: {:native, :millisecond}
34 | ),
35 | summary("phoenix.router_dispatch.exception.duration",
36 | tags: [:route],
37 | unit: {:native, :millisecond}
38 | ),
39 | summary("phoenix.router_dispatch.stop.duration",
40 | tags: [:route],
41 | unit: {:native, :millisecond}
42 | ),
43 | summary("phoenix.socket_connected.duration",
44 | unit: {:native, :millisecond}
45 | ),
46 | summary("phoenix.channel_joined.duration",
47 | unit: {:native, :millisecond}
48 | ),
49 | summary("phoenix.channel_handled_in.duration",
50 | tags: [:event],
51 | unit: {:native, :millisecond}
52 | ),
53 |
54 | # Database Metrics
55 | summary("phoenix_00.repo.query.total_time",
56 | unit: {:native, :millisecond},
57 | description: "The sum of the other measurements"
58 | ),
59 | summary("phoenix_00.repo.query.decode_time",
60 | unit: {:native, :millisecond},
61 | description: "The time spent decoding the data received from the database"
62 | ),
63 | summary("phoenix_00.repo.query.query_time",
64 | unit: {:native, :millisecond},
65 | description: "The time spent executing the query"
66 | ),
67 | summary("phoenix_00.repo.query.queue_time",
68 | unit: {:native, :millisecond},
69 | description: "The time spent waiting for a database connection"
70 | ),
71 | summary("phoenix_00.repo.query.idle_time",
72 | unit: {:native, :millisecond},
73 | description:
74 | "The time the connection spent waiting before being checked out for the query"
75 | ),
76 |
77 | # VM Metrics
78 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
79 | summary("vm.total_run_queue_lengths.total"),
80 | summary("vm.total_run_queue_lengths.cpu"),
81 | summary("vm.total_run_queue_lengths.io")
82 | ]
83 | end
84 |
85 | defp periodic_measurements do
86 | [
87 | # A module, function and arguments to be invoked periodically.
88 | # This function must call :telemetry.execute/3 and a metric must be added above.
89 | # {Phoenix00Web, :count_users, []}
90 | ]
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/lib/workers/send_email.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Workers.SendEmail do
2 | alias Phoenix00.Logs
3 | use Oban.Worker, queue: :mailer
4 | alias Phoenix00.Contacts
5 | alias Phoenix00.Mailer
6 | alias Phoenix00.Messages
7 | require Logger
8 |
9 | @impl Oban.Worker
10 | def perform(%Oban.Job{args: %{"email" => email_args}}) do
11 | recipients = get_destinations(email_args)
12 |
13 | with {:ok, email} <- Mailer.from_map(email_args),
14 | {:ok, metadata} <- Mailer.deliver(email),
15 | {:ok, email} <- create_email(email_args, metadata) do
16 | Enum.each(recipients, fn recipient -> create_message(email, recipient) end)
17 | :ok
18 | else
19 | {:error, e} ->
20 | Logs.create_log(%{
21 | status: 500,
22 | source: "queue:send",
23 | method: :post,
24 | request: email_args,
25 | response: %{error: e},
26 | token_id: email_args["token_id"],
27 | email_id: email_args["id"]
28 | })
29 |
30 | {:error, e}
31 |
32 | e ->
33 | Logs.create_log(%{
34 | status: 500,
35 | source: "queue:send",
36 | method: :post,
37 | request: email_args,
38 | response: %{error: e},
39 | token_id: email_args["token_id"],
40 | email_id: email_args["id"]
41 | })
42 |
43 | {:error, e}
44 | end
45 | end
46 |
47 | defp get_destinations(email) do
48 | to = List.wrap(email["to"])
49 | cc = List.wrap(email["cc"])
50 | bcc = List.wrap(email["bcc"])
51 |
52 | Enum.concat(to, cc) |> Enum.concat(bcc) |> Enum.filter(&(!is_nil(&1)))
53 | end
54 |
55 | defp create_message(email, destination) do
56 | recipient =
57 | Contacts.create_or_find_recipient_by_destination(%{destination: destination})
58 |
59 | Messages.create_message(%{
60 | status: :pending,
61 | recipient_id: recipient.id,
62 | transmission: email.id
63 | })
64 | end
65 |
66 | defp create_email(email_args, metadata) do
67 | Messages.add_email_sender(email_args, %{sender_id: metadata.id})
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/litestream.Dockerfile:
--------------------------------------------------------------------------------
1 | # Litestream parts copied from https://github.com/benbjohnson/litestream-docker-example/blob/main/Dockerfile
2 |
3 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
4 | # instead of Alpine to avoid DNS resolution issues in production.
5 | #
6 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
7 | # https://hub.docker.com/_/ubuntu?tab=tags
8 | #
9 | # This file is based on these images:
10 | #
11 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
12 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240513-slim - for the release image
13 | # - https://pkgs.org/ - resource for finding needed packages
14 | # - Ex: hexpm/elixir:1.16.2-erlang-26.2.5-debian-bullseye-20240513-slim
15 | #
16 | #
17 | ARG ELIXIR_VERSION=1.16.2
18 | ARG OTP_VERSION=26.2.5
19 | ARG DEBIAN_VERSION=bullseye-20240513-slim
20 |
21 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
22 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
23 |
24 | FROM ${BUILDER_IMAGE} as builder
25 |
26 | # install build dependencies
27 | RUN apt-get update -y && apt-get install -y build-essential git nodejs npm \
28 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
29 |
30 | # Download the static build of Litestream directly into the path & make it executable.
31 | # This is done in the builder and copied as the chmod doubles the size.
32 | ADD https://github.com/benbjohnson/litestream/releases/download/v0.3.8/litestream-v0.3.8-linux-amd64-static.tar.gz /tmp/litestream.tar.gz
33 | RUN tar -C /usr/local/bin -xzf /tmp/litestream.tar.gz
34 |
35 |
36 | # prepare build dir
37 | WORKDIR /app
38 |
39 | # install hex + rebar
40 | RUN mix local.hex --force && \
41 | mix local.rebar --force
42 |
43 | # set build ENV
44 | ENV MIX_ENV="prod"
45 | # Without this it is breaking on cross platform builds again https://elixirforum.com/t/mix-deps-get-memory-explosion-when-doing-cross-platform-docker-build/57157/3
46 | ENV ERL_FLAGS="+JPperf true"
47 | # install mix dependencies
48 | COPY mix.exs mix.lock ./
49 | RUN mix deps.get --only $MIX_ENV
50 | RUN mkdir config
51 |
52 | # copy compile-time config files before we compile dependencies
53 | # to ensure any relevant config change will trigger the dependencies
54 | # to be re-compiled.
55 | COPY config/config.exs config/${MIX_ENV}.exs config/
56 | RUN mix deps.compile
57 |
58 | COPY priv priv
59 |
60 | COPY lib lib
61 |
62 | COPY assets assets
63 |
64 | # FOR DAISY
65 | # Install node and npm
66 | # RUN apt-get install -y nodejs
67 | # Added for Daisy UI see this forum answer https://elixirforum.com/t/how-to-get-daisyui-and-phoenix-to-work/46612/9
68 | RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
69 | # compile assets
70 | RUN mix assets.deploy
71 |
72 | # Compile the release
73 | RUN mix compile
74 |
75 | # Changes to config/runtime.exs don't require recompiling the code
76 | COPY config/runtime.exs config/
77 |
78 | COPY rel rel
79 | RUN mix release
80 |
81 | # start a new build stage so that the final image will only contain
82 | # the compiled release and other runtime necessities
83 | FROM ${RUNNER_IMAGE}
84 |
85 | RUN apt-get update -y && \
86 | apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates pandoc \
87 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
88 |
89 | # Set the locale
90 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
91 |
92 | ENV LANG en_US.UTF-8
93 | ENV LANGUAGE en_US:en
94 | ENV LC_ALL en_US.UTF-8
95 |
96 | WORKDIR "/app"
97 | RUN chown nobody /app
98 |
99 | # set runner ENV
100 | ENV MIX_ENV="prod"
101 |
102 | # Only copy the final release & litestream from the build stage
103 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/phoenix_00 ./
104 | COPY --from=builder --chown=nobody:root /usr/local/bin/litestream /usr/local/bin/litestream
105 |
106 | # COPY etc/litestream.yml /etc/litestream.yml
107 | COPY scripts/litestream-docker.sh /scripts/litestream-docker.sh
108 |
109 | USER nobody
110 |
111 | EXPOSE 4000
112 |
113 | # If using an environment that doesn't automatically reap zombie processes, it is
114 | # advised to add an init process such as tini via `apt-get install`
115 | # above and adding an entrypoint. See https://github.com/krallin/tini for details
116 | # ENTRYPOINT ["/tini", "--"]
117 | # Copy Litestream configuration file & startup script.
118 |
119 |
120 | CMD [ "/scripts/litestream-docker.sh" ]
121 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :phoenix_00,
7 | version: "0.1.0",
8 | elixir: "~> 1.14",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | start_permanent: Mix.env() == :prod,
11 | aliases: aliases(),
12 | deps: deps()
13 | ]
14 | end
15 |
16 | # Configuration for the OTP application.
17 | #
18 | # Type `mix help compile.app` for more information.
19 | def application do
20 | [
21 | mod: {Phoenix00.Application, []},
22 | extra_applications: [:logger, :runtime_tools]
23 | ]
24 | end
25 |
26 | # Specifies which paths to compile per environment.
27 | defp elixirc_paths(:test), do: ["lib", "test/support"]
28 | defp elixirc_paths(_), do: ["lib"]
29 |
30 | # Specifies your project dependencies.
31 | #
32 | # Type `mix help deps` for examples and options.
33 | defp deps do
34 | [
35 | {:bcrypt_elixir, "~> 3.0"},
36 | {:phoenix, "~> 1.7.12"},
37 | {:phoenix_ecto, "~> 4.4"},
38 | {:ecto_sql, "~> 3.10"},
39 | {:postgrex, ">= 0.0.0"},
40 | {:phoenix_html, "~> 4.0"},
41 | {:phoenix_live_reload, "~> 1.2", only: :dev},
42 | {:phoenix_live_view, "~> 0.20.2"},
43 | {:floki, ">= 0.30.0", only: :test},
44 | {:phoenix_live_dashboard, "~> 0.8.3"},
45 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
46 | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
47 | {:heroicons,
48 | github: "tailwindlabs/heroicons",
49 | tag: "v2.1.1",
50 | sparse: "optimized",
51 | app: false,
52 | compile: false,
53 | depth: 1},
54 | {:swoosh, "~> 1.5"},
55 | {:gen_smtp, "~> 1.0"},
56 | {:ex_aws, "~> 2.0"},
57 | {:ex_aws_sns, "~> 2.0"},
58 | {:hackney, "~> 1.20"},
59 | {:finch, "~> 0.13"},
60 | {:telemetry_metrics, "~> 1.0"},
61 | {:telemetry_poller, "~> 1.0"},
62 | {:gettext, "~> 0.20"},
63 | {:jason, "~> 1.2"},
64 | {:dns_cluster, "~> 0.1.1"},
65 | {:bandit, "~> 1.2"},
66 | {:ecto_sqlite3, "~> 0.15"},
67 | {:oban, "~> 2.17"},
68 | {:mdex, "~> 0.1"},
69 | {:scribe, "~> 0.10"},
70 | {:broadway, "~> 1.0"},
71 | {:broadway_sqs, "~> 0.7"},
72 | {:fsmx, "~> 0.5.0"},
73 | {:timex, "~> 3.0"},
74 | {:flop, "~> 0.25.0"},
75 | {:flop_phoenix, "~> 0.22.9"},
76 | {:autumn, "~> 0.2.3"},
77 | {:pandex, "~> 0.2.0"}
78 | ]
79 | end
80 |
81 | # Aliases are shortcuts or tasks specific to the current project.
82 | # For example, to install project dependencies and perform other setup tasks, run:
83 | #
84 | # $ mix setup
85 | #
86 | # See the documentation for `Mix` for more info on aliases.
87 | defp aliases do
88 | [
89 | setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
90 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
91 | "ecto.reset": ["ecto.drop", "ecto.setup"],
92 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
93 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
94 | "assets.build": ["tailwind phoenix_00", "esbuild phoenix_00"],
95 | "assets.deploy": [
96 | "tailwind phoenix_00 --minify",
97 | "esbuild phoenix_00 --minify",
98 | "phx.digest"
99 | ]
100 | ]
101 | end
102 | end
103 |
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | ## From Ecto.Changeset.validate_length/3
53 | msgid "should have %{count} item(s)"
54 | msgid_plural "should have %{count} item(s)"
55 | msgstr[0] ""
56 | msgstr[1] ""
57 |
58 | msgid "should be %{count} character(s)"
59 | msgid_plural "should be %{count} character(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be %{count} byte(s)"
64 | msgid_plural "should be %{count} byte(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at least %{count} character(s)"
74 | msgid_plural "should be at least %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should be at least %{count} byte(s)"
79 | msgid_plural "should be at least %{count} byte(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | msgid "should have at most %{count} item(s)"
84 | msgid_plural "should have at most %{count} item(s)"
85 | msgstr[0] ""
86 | msgstr[1] ""
87 |
88 | msgid "should be at most %{count} character(s)"
89 | msgid_plural "should be at most %{count} character(s)"
90 | msgstr[0] ""
91 | msgstr[1] ""
92 |
93 | msgid "should be at most %{count} byte(s)"
94 | msgid_plural "should be at most %{count} byte(s)"
95 | msgstr[0] ""
96 | msgstr[1] ""
97 |
98 | ## From Ecto.Changeset.validate_number/3
99 | msgid "must be less than %{number}"
100 | msgstr ""
101 |
102 | msgid "must be greater than %{number}"
103 | msgstr ""
104 |
105 | msgid "must be less than or equal to %{number}"
106 | msgstr ""
107 |
108 | msgid "must be greater than or equal to %{number}"
109 | msgstr ""
110 |
111 | msgid "must be equal to %{number}"
112 | msgstr ""
113 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here has no
9 | ## effect: edit them in PO (`.po`) files instead.
10 | ## From Ecto.Changeset.cast/4
11 | msgid "can't be blank"
12 | msgstr ""
13 |
14 | ## From Ecto.Changeset.unique_constraint/3
15 | msgid "has already been taken"
16 | msgstr ""
17 |
18 | ## From Ecto.Changeset.put_change/3
19 | msgid "is invalid"
20 | msgstr ""
21 |
22 | ## From Ecto.Changeset.validate_acceptance/3
23 | msgid "must be accepted"
24 | msgstr ""
25 |
26 | ## From Ecto.Changeset.validate_format/3
27 | msgid "has invalid format"
28 | msgstr ""
29 |
30 | ## From Ecto.Changeset.validate_subset/3
31 | msgid "has an invalid entry"
32 | msgstr ""
33 |
34 | ## From Ecto.Changeset.validate_exclusion/3
35 | msgid "is reserved"
36 | msgstr ""
37 |
38 | ## From Ecto.Changeset.validate_confirmation/3
39 | msgid "does not match confirmation"
40 | msgstr ""
41 |
42 | ## From Ecto.Changeset.no_assoc_constraint/3
43 | msgid "is still associated with this entry"
44 | msgstr ""
45 |
46 | msgid "are still associated with this entry"
47 | msgstr ""
48 |
49 | ## From Ecto.Changeset.validate_length/3
50 | msgid "should have %{count} item(s)"
51 | msgid_plural "should have %{count} item(s)"
52 | msgstr[0] ""
53 | msgstr[1] ""
54 |
55 | msgid "should be %{count} character(s)"
56 | msgid_plural "should be %{count} character(s)"
57 | msgstr[0] ""
58 | msgstr[1] ""
59 |
60 | msgid "should be %{count} byte(s)"
61 | msgid_plural "should be %{count} byte(s)"
62 | msgstr[0] ""
63 | msgstr[1] ""
64 |
65 | msgid "should have at least %{count} item(s)"
66 | msgid_plural "should have at least %{count} item(s)"
67 | msgstr[0] ""
68 | msgstr[1] ""
69 |
70 | msgid "should be at least %{count} character(s)"
71 | msgid_plural "should be at least %{count} character(s)"
72 | msgstr[0] ""
73 | msgstr[1] ""
74 |
75 | msgid "should be at least %{count} byte(s)"
76 | msgid_plural "should be at least %{count} byte(s)"
77 | msgstr[0] ""
78 | msgstr[1] ""
79 |
80 | msgid "should have at most %{count} item(s)"
81 | msgid_plural "should have at most %{count} item(s)"
82 | msgstr[0] ""
83 | msgstr[1] ""
84 |
85 | msgid "should be at most %{count} character(s)"
86 | msgid_plural "should be at most %{count} character(s)"
87 | msgstr[0] ""
88 | msgstr[1] ""
89 |
90 | msgid "should be at most %{count} byte(s)"
91 | msgid_plural "should be at most %{count} byte(s)"
92 | msgstr[0] ""
93 | msgstr[1] ""
94 |
95 | ## From Ecto.Changeset.validate_number/3
96 | msgid "must be less than %{number}"
97 | msgstr ""
98 |
99 | msgid "must be greater than %{number}"
100 | msgstr ""
101 |
102 | msgid "must be less than or equal to %{number}"
103 | msgstr ""
104 |
105 | msgid "must be greater than or equal to %{number}"
106 | msgstr ""
107 |
108 | msgid "must be equal to %{number}"
109 | msgstr ""
110 |
--------------------------------------------------------------------------------
/priv/repo/migrations/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto_sql],
3 | inputs: ["*.exs"]
4 | ]
5 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240518132719_create_users_auth_tables.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Repo.Migrations.CreateUsersAuthTables do
2 | use Ecto.Migration
3 |
4 | def change do
5 | # execute "CREATE EXTENSION IF NOT EXISTS citext", ""
6 |
7 | create table(:users) do
8 | add :email, :citext, null: false
9 | add :hashed_password, :string, null: false
10 | add :confirmed_at, :naive_datetime
11 | timestamps(type: :utc_datetime)
12 | end
13 |
14 | create unique_index(:users, [:email])
15 |
16 | create table(:users_tokens) do
17 | add :user_id, references(:users, on_delete: :delete_all), null: false
18 | add :token, :binary, null: false
19 | add :context, :string, null: false
20 | add :sent_to, :string
21 | add :name, :string
22 | timestamps(updated_at: false)
23 | end
24 |
25 | create index(:users_tokens, [:user_id])
26 | create unique_index(:users_tokens, [:context, :token])
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240518134826_create_emails.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Repo.Migrations.CreateEmails do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:emails) do
6 | add :sender_id, :string
7 | add :to, {:array, :string}
8 | add :cc, {:array, :string}
9 | add :bcc, {:array, :string}
10 | add :reply_to, {:array, :string}
11 | add :from, :string
12 | add :status, :string
13 | add :email_id, :string
14 | add :batch, :string
15 | add :body, :string
16 | add :text, :string
17 | add :subject, :string
18 | add :sent_by, references(:users, on_delete: :nothing)
19 |
20 | timestamps(type: :utc_datetime)
21 | end
22 |
23 | create unique_index(:emails, [:email_id])
24 | create unique_index(:emails, [:sender_id])
25 | create index(:emails, [:sent_by])
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240524230521_add_oban_jobs_table.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Repo.Migrations.AddObanJobsTable do
2 | use Ecto.Migration
3 |
4 | def up do
5 | Oban.Migration.up(version: 12)
6 | end
7 |
8 | # We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if
9 | # necessary, regardless of which version we've migrated `up` to.
10 | def down do
11 | Oban.Migration.down(version: 1)
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240531122709_create_recipients.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Repo.Migrations.CreateRecipients do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:recipients) do
6 | add :destination, :string
7 |
8 | timestamps(type: :utc_datetime)
9 | end
10 |
11 | create unique_index(:recipients, :destination)
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240531123454_create_messages.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Repo.Migrations.CreateMessages do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:messages) do
6 | add :status, :string
7 | add :recipient_id, references(:recipients, on_delete: :nothing)
8 | add :transmission, references(:emails, on_delete: :nothing)
9 |
10 | timestamps(type: :utc_datetime)
11 | end
12 |
13 | create index(:messages, [:recipient_id])
14 | create index(:messages, [:transmission])
15 | create unique_index(:messages, [:recipient_id, :transmission])
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240610034222_create_events.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Repo.Migrations.CreateEvents do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:events) do
6 | add :status, :string
7 | add :message_id, references(:messages, on_delete: :nothing)
8 |
9 | timestamps(type: :utc_datetime)
10 | end
11 |
12 | create index(:events, [:message_id])
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240615202700_create_logs.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.Repo.Migrations.CreateLogs do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:logs) do
6 | add :status, :integer
7 | add :source, :string
8 | add :method, :string
9 | add :response, :map, default: "{}"
10 | add :request, :map, default: "{}"
11 | add :token_id, :id
12 | add :email_id, references(:emails, on_delete: :nothing)
13 |
14 | timestamps(type: :utc_datetime)
15 | end
16 |
17 | create index(:logs, [:email_id])
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/priv/repo/migrations/seeds.exs:
--------------------------------------------------------------------------------
1 | # Script for populating the database. You can run it as:
2 | #
3 | # mix run priv/repo/seeds.exs
4 | #
5 | # Inside the script, you can read and write to any of your
6 | # repositories directly:
7 | #
8 | # Phoenix00.Repo.insert!(%Phoenix00.SomeSchema{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
--------------------------------------------------------------------------------
/priv/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/technomancy-dev/00/16901a9eaf5d51b957d07f0c0c061d6c1027c274/priv/static/favicon.ico
--------------------------------------------------------------------------------
/priv/static/images/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
--------------------------------------------------------------------------------
/priv/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/rel/overlays/bin/migrate:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -eu
3 |
4 | cd -P -- "$(dirname -- "$0")"
5 | exec ./phoenix_00 eval Phoenix00.Release.migrate
6 |
--------------------------------------------------------------------------------
/rel/overlays/bin/migrate.bat:
--------------------------------------------------------------------------------
1 | call "%~dp0\phoenix_00" eval Phoenix00.Release.migrate
2 |
--------------------------------------------------------------------------------
/rel/overlays/bin/server:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -eu
3 |
4 | cd -P -- "$(dirname -- "$0")"
5 | PHX_SERVER=true exec ./phoenix_00 start
6 |
--------------------------------------------------------------------------------
/rel/overlays/bin/server.bat:
--------------------------------------------------------------------------------
1 | set PHX_SERVER=true
2 | call "%~dp0\phoenix_00" start
3 |
--------------------------------------------------------------------------------
/scripts/litestream-docker.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # Restore the database if it does not already exist.
5 | if [ -f "/app/${DATABASE_PATH}" ]; then
6 | echo "Database already exists, skipping restore"
7 | else
8 | echo "No database found, restoring from replica if exists ${REPLICA_URL}"
9 | litestream restore -v -if-replica-exists -o "/app/bin/${DATABASE_PATH}" "${REPLICA_URL}"
10 | fi
11 |
12 | exec litestream replicate -exec "/app/bin/server" "/app/bin/00.db" "${REPLICA_URL}"
13 |
--------------------------------------------------------------------------------
/sst-env.d.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | import "sst"
4 | declare module "sst" {
5 | export interface Resource {
6 | ZeroEmailSNS: {
7 | arn: string
8 | type: "sst.aws.SnsTopic"
9 | }
10 | ZeroEmailSQS: {
11 | type: "sst.aws.Queue"
12 | url: string
13 | }
14 | }
15 | }
16 | export {}
--------------------------------------------------------------------------------
/sst.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | export default $config({
4 | app(input) {
5 | return {
6 | name: "double-zero",
7 | removal: input?.stage === "production" ? "retain" : "remove",
8 | home: "aws",
9 | };
10 | },
11 | async run() {
12 | if (!process.env.EMAIL_IDENTITY) {
13 | const message = "Need to set EMAIL_IDENTITY env variable.";
14 | console.error(message);
15 | throw Error(message);
16 | }
17 |
18 | const queue = new sst.aws.Queue("ZeroEmailSQS");
19 |
20 | let service;
21 | if (
22 | process.env.SST_DEPLOY &&
23 | process.env.PHX_HOST &&
24 | process.env.DATABASE_PATH
25 | ) {
26 | const vpc = new sst.aws.Vpc("ZeroEmailVpc");
27 |
28 | const cluster = new sst.aws.Cluster("ZeroEmailCluster", { vpc });
29 |
30 | const bucket = new sst.aws.Bucket("ZeroSQLiteBucket", {
31 | public: false,
32 | });
33 |
34 | service = cluster.addService("ZeroEmailService", {
35 | public: {
36 | domain: {
37 | name: process.env.PHX_HOST,
38 | },
39 | ports: [
40 | { listen: "80/http", forward: "4000/http" },
41 | { listen: "443/https", forward: "4000/http" },
42 | ],
43 | },
44 | environment: {
45 | REPLICA_URL: $interpolate`s3://${bucket.name}/db`,
46 | BUCKETNAME: bucket.name,
47 | AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY!,
48 | AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID!,
49 | AWS_REGION: process.env.AWS_REGION!,
50 | DATABASE_PATH: process.env.DATABASE_PATH!,
51 | SYSTEM_EMAIL: process.env.SYSTEM_EMAIL!,
52 | SQS_URL: queue.url,
53 | SECRET_KEY_BASE: process.env.SECRET_KEY_BASE!,
54 | PHX_HOST: process.env.PHX_HOST!,
55 | },
56 | image: {
57 | dockerfile: "litestream.Dockerfile",
58 | },
59 | link: [bucket],
60 | });
61 | }
62 |
63 | const topic = new sst.aws.SnsTopic("ZeroEmailSNS");
64 |
65 | topic.subscribeQueue(queue.arn);
66 |
67 | const configSet = new aws.sesv2.ConfigurationSet("ZeroEmailConfigSet", {
68 | configurationSetName: "zero_email_events",
69 | });
70 |
71 | const exampleConfigurationSetEventDestination =
72 | new aws.sesv2.ConfigurationSetEventDestination("ZeroEmailEvent", {
73 | configurationSetName: configSet.configurationSetName,
74 | eventDestinationName: "zero_email_sns",
75 | eventDestination: {
76 | snsDestination: {
77 | topicArn: topic.arn,
78 | },
79 | enabled: true,
80 | matchingEventTypes: [
81 | "SEND",
82 | "REJECT",
83 | "BOUNCE",
84 | "COMPLAINT",
85 | "DELIVERY",
86 | // TODO: Handle all these.
87 | // "OPEN",
88 | // "CLICK",
89 | // "RENDERING_FAILURE",
90 | // "DELIVERY_DELAY",
91 | //
92 | // Wont handle as we have internal list management.
93 | // "SUBSCRIPTION",
94 | ],
95 | },
96 | });
97 |
98 | const exampleEmailIdentity = new aws.sesv2.EmailIdentity("ZeroEmail", {
99 | emailIdentity: process.env.EMAIL_IDENTITY || "",
100 | configurationSetName: configSet.configurationSetName,
101 | });
102 |
103 | if (process.env.UNSUBSCRIBE_EMAIL) {
104 | const unsubscribe_rule_set = new aws.ses.ReceiptRuleSet(
105 | "ZeroUnsubscribeSet",
106 | { ruleSetName: "zero-unsubscribe-rule-set" },
107 | );
108 | const unsubscribe_rule = new aws.ses.ReceiptRule("ZeroUnsubscribeRule", {
109 | name: "unsubscribe",
110 | ruleSetName: unsubscribe_rule_set.ruleSetName,
111 | recipients: [process.env.UNSUBSCRIBE_EMAIL],
112 | enabled: true,
113 | snsActions: [
114 | {
115 | position: 0,
116 | topicArn: topic.arn,
117 | },
118 | ],
119 | });
120 |
121 | const main = new aws.ses.ActiveReceiptRuleSet(
122 | "ZeroUnsubscribeRuleActive",
123 | { ruleSetName: unsubscribe_rule_set.ruleSetName },
124 | );
125 | }
126 |
127 | return { queue: queue.url, url: service?.url };
128 | },
129 | });
130 |
--------------------------------------------------------------------------------
/test/phoenix_00/contacts_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.ContactsTest do
2 | use Phoenix00.DataCase
3 |
4 | alias Phoenix00.Contacts
5 |
6 | describe "recipients" do
7 | alias Phoenix00.Contacts.Recipient
8 |
9 | import Phoenix00.ContactsFixtures
10 |
11 | @invalid_attrs %{destination: nil}
12 |
13 | test "list_recipients/0 returns all recipients" do
14 | recipient = recipient_fixture()
15 | assert Contacts.list_recipients() == [recipient]
16 | end
17 |
18 | test "get_recipient!/1 returns the recipient with given id" do
19 | recipient = recipient_fixture()
20 | assert Contacts.get_recipient!(recipient.id) == recipient
21 | end
22 |
23 | test "create_recipient/1 with valid data creates a recipient" do
24 | valid_attrs = %{destination: "some destination"}
25 |
26 | assert {:ok, %Recipient{} = recipient} = Contacts.create_recipient(valid_attrs)
27 | assert recipient.destination == "some destination"
28 | end
29 |
30 | test "create_recipient/1 with invalid data returns error changeset" do
31 | assert {:error, %Ecto.Changeset{}} = Contacts.create_recipient(@invalid_attrs)
32 | end
33 |
34 | test "update_recipient/2 with valid data updates the recipient" do
35 | recipient = recipient_fixture()
36 | update_attrs = %{destination: "some updated destination"}
37 |
38 | assert {:ok, %Recipient{} = recipient} = Contacts.update_recipient(recipient, update_attrs)
39 | assert recipient.destination == "some updated destination"
40 | end
41 |
42 | test "update_recipient/2 with invalid data returns error changeset" do
43 | recipient = recipient_fixture()
44 | assert {:error, %Ecto.Changeset{}} = Contacts.update_recipient(recipient, @invalid_attrs)
45 | assert recipient == Contacts.get_recipient!(recipient.id)
46 | end
47 |
48 | test "delete_recipient/1 deletes the recipient" do
49 | recipient = recipient_fixture()
50 | assert {:ok, %Recipient{}} = Contacts.delete_recipient(recipient)
51 | assert_raise Ecto.NoResultsError, fn -> Contacts.get_recipient!(recipient.id) end
52 | end
53 |
54 | test "change_recipient/1 returns a recipient changeset" do
55 | recipient = recipient_fixture()
56 | assert %Ecto.Changeset{} = Contacts.change_recipient(recipient)
57 | end
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/test/phoenix_00/events_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.EventsTest do
2 | use Phoenix00.DataCase
3 |
4 | alias Phoenix00.Events
5 |
6 | describe "events" do
7 | alias Phoenix00.Events.Event
8 |
9 | import Phoenix00.EventsFixtures
10 |
11 | @invalid_attrs %{status: nil}
12 |
13 | test "list_events/0 returns all events" do
14 | event = event_fixture()
15 | assert Events.list_events() == [event]
16 | end
17 |
18 | test "get_event!/1 returns the event with given id" do
19 | event = event_fixture()
20 | assert Events.get_event!(event.id) == event
21 | end
22 |
23 | test "create_event/1 with valid data creates a event" do
24 | valid_attrs = %{status: "some status"}
25 |
26 | assert {:ok, %Event{} = event} = Events.create_event(valid_attrs)
27 | assert event.status == "some status"
28 | end
29 |
30 | test "create_event/1 with invalid data returns error changeset" do
31 | assert {:error, %Ecto.Changeset{}} = Events.create_event(@invalid_attrs)
32 | end
33 |
34 | test "update_event/2 with valid data updates the event" do
35 | event = event_fixture()
36 | update_attrs = %{status: "some updated status"}
37 |
38 | assert {:ok, %Event{} = event} = Events.update_event(event, update_attrs)
39 | assert event.status == "some updated status"
40 | end
41 |
42 | test "update_event/2 with invalid data returns error changeset" do
43 | event = event_fixture()
44 | assert {:error, %Ecto.Changeset{}} = Events.update_event(event, @invalid_attrs)
45 | assert event == Events.get_event!(event.id)
46 | end
47 |
48 | test "delete_event/1 deletes the event" do
49 | event = event_fixture()
50 | assert {:ok, %Event{}} = Events.delete_event(event)
51 | assert_raise Ecto.NoResultsError, fn -> Events.get_event!(event.id) end
52 | end
53 |
54 | test "change_event/1 returns a event changeset" do
55 | event = event_fixture()
56 | assert %Ecto.Changeset{} = Events.change_event(event)
57 | end
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/test/phoenix_00/logs_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.LogsTest do
2 | use Phoenix00.DataCase
3 |
4 | alias Phoenix00.Logs
5 |
6 | describe "logs" do
7 | alias Phoenix00.Logs.Log
8 |
9 | import Phoenix00.LogsFixtures
10 |
11 | @invalid_attrs %{status: nil, request: nil, response: nil, endpoint: nil, method: nil, ke_name: nil}
12 |
13 | test "list_logs/0 returns all logs" do
14 | log = log_fixture()
15 | assert Logs.list_logs() == [log]
16 | end
17 |
18 | test "get_log!/1 returns the log with given id" do
19 | log = log_fixture()
20 | assert Logs.get_log!(log.id) == log
21 | end
22 |
23 | test "create_log/1 with valid data creates a log" do
24 | valid_attrs = %{status: 42, request: %{}, response: %{}, endpoint: "some endpoint", method: :get, ke_name: "some ke_name"}
25 |
26 | assert {:ok, %Log{} = log} = Logs.create_log(valid_attrs)
27 | assert log.status == 42
28 | assert log.request == %{}
29 | assert log.response == %{}
30 | assert log.endpoint == "some endpoint"
31 | assert log.method == :get
32 | assert log.ke_name == "some ke_name"
33 | end
34 |
35 | test "create_log/1 with invalid data returns error changeset" do
36 | assert {:error, %Ecto.Changeset{}} = Logs.create_log(@invalid_attrs)
37 | end
38 |
39 | test "update_log/2 with valid data updates the log" do
40 | log = log_fixture()
41 | update_attrs = %{status: 43, request: %{}, response: %{}, endpoint: "some updated endpoint", method: :head, ke_name: "some updated ke_name"}
42 |
43 | assert {:ok, %Log{} = log} = Logs.update_log(log, update_attrs)
44 | assert log.status == 43
45 | assert log.request == %{}
46 | assert log.response == %{}
47 | assert log.endpoint == "some updated endpoint"
48 | assert log.method == :head
49 | assert log.ke_name == "some updated ke_name"
50 | end
51 |
52 | test "update_log/2 with invalid data returns error changeset" do
53 | log = log_fixture()
54 | assert {:error, %Ecto.Changeset{}} = Logs.update_log(log, @invalid_attrs)
55 | assert log == Logs.get_log!(log.id)
56 | end
57 |
58 | test "delete_log/1 deletes the log" do
59 | log = log_fixture()
60 | assert {:ok, %Log{}} = Logs.delete_log(log)
61 | assert_raise Ecto.NoResultsError, fn -> Logs.get_log!(log.id) end
62 | end
63 |
64 | test "change_log/1 returns a log changeset" do
65 | log = log_fixture()
66 | assert %Ecto.Changeset{} = Logs.change_log(log)
67 | end
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/test/phoenix_00/messages_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.MessagesTest do
2 | use Phoenix00.DataCase
3 |
4 | alias Phoenix00.Messages
5 |
6 | describe "emails" do
7 | alias Phoenix00.Messages.Email
8 |
9 | import Phoenix00.MessagesFixtures
10 |
11 | @invalid_attrs %{status: nil, to: nil, from: nil, aws_message_id: nil, email_id: nil}
12 |
13 | test "list_emails/0 returns all emails" do
14 | email = email_fixture()
15 | assert Messages.list_emails() == [email]
16 | end
17 |
18 | test "get_email!/1 returns the email with given id" do
19 | email = email_fixture()
20 | assert Messages.get_email!(email.id) == email
21 | end
22 |
23 | test "create_email/1 with valid data creates a email" do
24 | valid_attrs = %{status: :pending, to: "some to", from: "some from", aws_message_id: "some aws_message_id", email_id: "some email_id"}
25 |
26 | assert {:ok, %Email{} = email} = Messages.create_email(valid_attrs)
27 | assert email.status == :pending
28 | assert email.to == "some to"
29 | assert email.from == "some from"
30 | assert email.aws_message_id == "some aws_message_id"
31 | assert email.email_id == "some email_id"
32 | end
33 |
34 | test "create_email/1 with invalid data returns error changeset" do
35 | assert {:error, %Ecto.Changeset{}} = Messages.create_email(@invalid_attrs)
36 | end
37 |
38 | test "update_email/2 with valid data updates the email" do
39 | email = email_fixture()
40 | update_attrs = %{status: :sent, to: "some updated to", from: "some updated from", aws_message_id: "some updated aws_message_id", email_id: "some updated email_id"}
41 |
42 | assert {:ok, %Email{} = email} = Messages.update_email(email, update_attrs)
43 | assert email.status == :sent
44 | assert email.to == "some updated to"
45 | assert email.from == "some updated from"
46 | assert email.aws_message_id == "some updated aws_message_id"
47 | assert email.email_id == "some updated email_id"
48 | end
49 |
50 | test "update_email/2 with invalid data returns error changeset" do
51 | email = email_fixture()
52 | assert {:error, %Ecto.Changeset{}} = Messages.update_email(email, @invalid_attrs)
53 | assert email == Messages.get_email!(email.id)
54 | end
55 |
56 | test "delete_email/1 deletes the email" do
57 | email = email_fixture()
58 | assert {:ok, %Email{}} = Messages.delete_email(email)
59 | assert_raise Ecto.NoResultsError, fn -> Messages.get_email!(email.id) end
60 | end
61 |
62 | test "change_email/1 returns a email changeset" do
63 | email = email_fixture()
64 | assert %Ecto.Changeset{} = Messages.change_email(email)
65 | end
66 | end
67 |
68 | describe "messages" do
69 | alias Phoenix00.Messages.Message
70 |
71 | import Phoenix00.MessagesFixtures
72 |
73 | @invalid_attrs %{status: nil}
74 |
75 | test "list_messages/0 returns all messages" do
76 | message = message_fixture()
77 | assert Messages.list_messages() == [message]
78 | end
79 |
80 | test "get_message!/1 returns the message with given id" do
81 | message = message_fixture()
82 | assert Messages.get_message!(message.id) == message
83 | end
84 |
85 | test "create_message/1 with valid data creates a message" do
86 | valid_attrs = %{status: :pending}
87 |
88 | assert {:ok, %Message{} = message} = Messages.create_message(valid_attrs)
89 | assert message.status == :pending
90 | end
91 |
92 | test "create_message/1 with invalid data returns error changeset" do
93 | assert {:error, %Ecto.Changeset{}} = Messages.create_message(@invalid_attrs)
94 | end
95 |
96 | test "update_message/2 with valid data updates the message" do
97 | message = message_fixture()
98 | update_attrs = %{status: :sent}
99 |
100 | assert {:ok, %Message{} = message} = Messages.update_message(message, update_attrs)
101 | assert message.status == :sent
102 | end
103 |
104 | test "update_message/2 with invalid data returns error changeset" do
105 | message = message_fixture()
106 | assert {:error, %Ecto.Changeset{}} = Messages.update_message(message, @invalid_attrs)
107 | assert message == Messages.get_message!(message.id)
108 | end
109 |
110 | test "delete_message/1 deletes the message" do
111 | message = message_fixture()
112 | assert {:ok, %Message{}} = Messages.delete_message(message)
113 | assert_raise Ecto.NoResultsError, fn -> Messages.get_message!(message.id) end
114 | end
115 |
116 | test "change_message/1 returns a message changeset" do
117 | message = message_fixture()
118 | assert %Ecto.Changeset{} = Messages.change_message(message)
119 | end
120 | end
121 | end
122 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/controllers/error_html_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.ErrorHTMLTest do
2 | use Phoenix00Web.ConnCase, async: true
3 |
4 | # Bring render_to_string/4 for testing custom views
5 | import Phoenix.Template
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(Phoenix00Web.ErrorHTML, "404", "html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(Phoenix00Web.ErrorHTML, "500", "html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/controllers/error_json_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.ErrorJSONTest do
2 | use Phoenix00Web.ConnCase, async: true
3 |
4 | test "renders 404" do
5 | assert Phoenix00Web.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
6 | end
7 |
8 | test "renders 500" do
9 | assert Phoenix00Web.ErrorJSON.render("500.json", %{}) ==
10 | %{errors: %{detail: "Internal Server Error"}}
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.PageControllerTest do
2 | use Phoenix00Web.ConnCase
3 |
4 | test "GET /", %{conn: conn} do
5 | conn = get(conn, ~p"/")
6 | assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/controllers/user_session_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserSessionControllerTest do
2 | use Phoenix00Web.ConnCase, async: true
3 |
4 | import Phoenix00.AccountsFixtures
5 |
6 | setup do
7 | %{user: user_fixture()}
8 | end
9 |
10 | describe "POST /users/log_in" do
11 | test "logs the user in", %{conn: conn, user: user} do
12 | conn =
13 | post(conn, ~p"/users/log_in", %{
14 | "user" => %{"email" => user.email, "password" => valid_user_password()}
15 | })
16 |
17 | assert get_session(conn, :user_token)
18 | assert redirected_to(conn) == ~p"/"
19 |
20 | # Now do a logged in request and assert on the menu
21 | conn = get(conn, ~p"/")
22 | response = html_response(conn, 200)
23 | assert response =~ user.email
24 | assert response =~ ~p"/users/settings"
25 | assert response =~ ~p"/users/log_out"
26 | end
27 |
28 | test "logs the user in with remember me", %{conn: conn, user: user} do
29 | conn =
30 | post(conn, ~p"/users/log_in", %{
31 | "user" => %{
32 | "email" => user.email,
33 | "password" => valid_user_password(),
34 | "remember_me" => "true"
35 | }
36 | })
37 |
38 | assert conn.resp_cookies["_phoenix00_web_user_remember_me"]
39 | assert redirected_to(conn) == ~p"/"
40 | end
41 |
42 | test "logs the user in with return to", %{conn: conn, user: user} do
43 | conn =
44 | conn
45 | |> init_test_session(user_return_to: "/foo/bar")
46 | |> post(~p"/users/log_in", %{
47 | "user" => %{
48 | "email" => user.email,
49 | "password" => valid_user_password()
50 | }
51 | })
52 |
53 | assert redirected_to(conn) == "/foo/bar"
54 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!"
55 | end
56 |
57 | test "login following registration", %{conn: conn, user: user} do
58 | conn =
59 | conn
60 | |> post(~p"/users/log_in", %{
61 | "_action" => "registered",
62 | "user" => %{
63 | "email" => user.email,
64 | "password" => valid_user_password()
65 | }
66 | })
67 |
68 | assert redirected_to(conn) == ~p"/"
69 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully"
70 | end
71 |
72 | test "login following password update", %{conn: conn, user: user} do
73 | conn =
74 | conn
75 | |> post(~p"/users/log_in", %{
76 | "_action" => "password_updated",
77 | "user" => %{
78 | "email" => user.email,
79 | "password" => valid_user_password()
80 | }
81 | })
82 |
83 | assert redirected_to(conn) == ~p"/users/settings"
84 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully"
85 | end
86 |
87 | test "redirects to login page with invalid credentials", %{conn: conn} do
88 | conn =
89 | post(conn, ~p"/users/log_in", %{
90 | "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"}
91 | })
92 |
93 | assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password"
94 | assert redirected_to(conn) == ~p"/users/log_in"
95 | end
96 | end
97 |
98 | describe "DELETE /users/log_out" do
99 | test "logs the user out", %{conn: conn, user: user} do
100 | conn = conn |> log_in_user(user) |> delete(~p"/users/log_out")
101 | assert redirected_to(conn) == ~p"/"
102 | refute get_session(conn, :user_token)
103 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
104 | end
105 |
106 | test "succeeds even if the user is not logged in", %{conn: conn} do
107 | conn = delete(conn, ~p"/users/log_out")
108 | assert redirected_to(conn) == ~p"/"
109 | refute get_session(conn, :user_token)
110 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
111 | end
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/live/email_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.EmailLiveTest do
2 | use Phoenix00Web.ConnCase
3 |
4 | import Phoenix.LiveViewTest
5 | import Phoenix00.MessagesFixtures
6 |
7 | @create_attrs %{status: :pending, to: "some to", from: "some from", aws_message_id: "some aws_message_id", email_id: "some email_id"}
8 | @update_attrs %{status: :sent, to: "some updated to", from: "some updated from", aws_message_id: "some updated aws_message_id", email_id: "some updated email_id"}
9 | @invalid_attrs %{status: nil, to: nil, from: nil, aws_message_id: nil, email_id: nil}
10 |
11 | defp create_email(_) do
12 | email = email_fixture()
13 | %{email: email}
14 | end
15 |
16 | describe "Index" do
17 | setup [:create_email]
18 |
19 | test "lists all emails", %{conn: conn, email: email} do
20 | {:ok, _index_live, html} = live(conn, ~p"/emails")
21 |
22 | assert html =~ "Listing Emails"
23 | assert html =~ email.to
24 | end
25 |
26 | test "saves new email", %{conn: conn} do
27 | {:ok, index_live, _html} = live(conn, ~p"/emails")
28 |
29 | assert index_live |> element("a", "New Email") |> render_click() =~
30 | "New Email"
31 |
32 | assert_patch(index_live, ~p"/emails/new")
33 |
34 | assert index_live
35 | |> form("#email-form", email: @invalid_attrs)
36 | |> render_change() =~ "can't be blank"
37 |
38 | assert index_live
39 | |> form("#email-form", email: @create_attrs)
40 | |> render_submit()
41 |
42 | assert_patch(index_live, ~p"/emails")
43 |
44 | html = render(index_live)
45 | assert html =~ "Email created successfully"
46 | assert html =~ "some to"
47 | end
48 |
49 | test "updates email in listing", %{conn: conn, email: email} do
50 | {:ok, index_live, _html} = live(conn, ~p"/emails")
51 |
52 | assert index_live |> element("#emails-#{email.id} a", "Edit") |> render_click() =~
53 | "Edit Email"
54 |
55 | assert_patch(index_live, ~p"/emails/#{email}/edit")
56 |
57 | assert index_live
58 | |> form("#email-form", email: @invalid_attrs)
59 | |> render_change() =~ "can't be blank"
60 |
61 | assert index_live
62 | |> form("#email-form", email: @update_attrs)
63 | |> render_submit()
64 |
65 | assert_patch(index_live, ~p"/emails")
66 |
67 | html = render(index_live)
68 | assert html =~ "Email updated successfully"
69 | assert html =~ "some updated to"
70 | end
71 |
72 | test "deletes email in listing", %{conn: conn, email: email} do
73 | {:ok, index_live, _html} = live(conn, ~p"/emails")
74 |
75 | assert index_live |> element("#emails-#{email.id} a", "Delete") |> render_click()
76 | refute has_element?(index_live, "#emails-#{email.id}")
77 | end
78 | end
79 |
80 | describe "Show" do
81 | setup [:create_email]
82 |
83 | test "displays email", %{conn: conn, email: email} do
84 | {:ok, _show_live, html} = live(conn, ~p"/emails/#{email}")
85 |
86 | assert html =~ "Show Email"
87 | assert html =~ email.to
88 | end
89 |
90 | test "updates email within modal", %{conn: conn, email: email} do
91 | {:ok, show_live, _html} = live(conn, ~p"/emails/#{email}")
92 |
93 | assert show_live |> element("a", "Edit") |> render_click() =~
94 | "Edit Email"
95 |
96 | assert_patch(show_live, ~p"/emails/#{email}/show/edit")
97 |
98 | assert show_live
99 | |> form("#email-form", email: @invalid_attrs)
100 | |> render_change() =~ "can't be blank"
101 |
102 | assert show_live
103 | |> form("#email-form", email: @update_attrs)
104 | |> render_submit()
105 |
106 | assert_patch(show_live, ~p"/emails/#{email}")
107 |
108 | html = render(show_live)
109 | assert html =~ "Email updated successfully"
110 | assert html =~ "some updated to"
111 | end
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/live/log_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.LogLiveTest do
2 | use Phoenix00Web.ConnCase
3 |
4 | import Phoenix.LiveViewTest
5 | import Phoenix00.LogsFixtures
6 |
7 | @create_attrs %{status: 42, request: %{}, response: %{}, endpoint: "some endpoint", method: :get, ke_name: "some ke_name"}
8 | @update_attrs %{status: 43, request: %{}, response: %{}, endpoint: "some updated endpoint", method: :head, ke_name: "some updated ke_name"}
9 | @invalid_attrs %{status: nil, request: nil, response: nil, endpoint: nil, method: nil, ke_name: nil}
10 |
11 | defp create_log(_) do
12 | log = log_fixture()
13 | %{log: log}
14 | end
15 |
16 | describe "Index" do
17 | setup [:create_log]
18 |
19 | test "lists all logs", %{conn: conn, log: log} do
20 | {:ok, _index_live, html} = live(conn, ~p"/logs")
21 |
22 | assert html =~ "Listing Logs"
23 | assert html =~ log.endpoint
24 | end
25 |
26 | test "saves new log", %{conn: conn} do
27 | {:ok, index_live, _html} = live(conn, ~p"/logs")
28 |
29 | assert index_live |> element("a", "New Log") |> render_click() =~
30 | "New Log"
31 |
32 | assert_patch(index_live, ~p"/logs/new")
33 |
34 | assert index_live
35 | |> form("#log-form", log: @invalid_attrs)
36 | |> render_change() =~ "can't be blank"
37 |
38 | assert index_live
39 | |> form("#log-form", log: @create_attrs)
40 | |> render_submit()
41 |
42 | assert_patch(index_live, ~p"/logs")
43 |
44 | html = render(index_live)
45 | assert html =~ "Log created successfully"
46 | assert html =~ "some endpoint"
47 | end
48 |
49 | test "updates log in listing", %{conn: conn, log: log} do
50 | {:ok, index_live, _html} = live(conn, ~p"/logs")
51 |
52 | assert index_live |> element("#logs-#{log.id} a", "Edit") |> render_click() =~
53 | "Edit Log"
54 |
55 | assert_patch(index_live, ~p"/logs/#{log}/edit")
56 |
57 | assert index_live
58 | |> form("#log-form", log: @invalid_attrs)
59 | |> render_change() =~ "can't be blank"
60 |
61 | assert index_live
62 | |> form("#log-form", log: @update_attrs)
63 | |> render_submit()
64 |
65 | assert_patch(index_live, ~p"/logs")
66 |
67 | html = render(index_live)
68 | assert html =~ "Log updated successfully"
69 | assert html =~ "some updated endpoint"
70 | end
71 |
72 | test "deletes log in listing", %{conn: conn, log: log} do
73 | {:ok, index_live, _html} = live(conn, ~p"/logs")
74 |
75 | assert index_live |> element("#logs-#{log.id} a", "Delete") |> render_click()
76 | refute has_element?(index_live, "#logs-#{log.id}")
77 | end
78 | end
79 |
80 | describe "Show" do
81 | setup [:create_log]
82 |
83 | test "displays log", %{conn: conn, log: log} do
84 | {:ok, _show_live, html} = live(conn, ~p"/logs/#{log}")
85 |
86 | assert html =~ "Show Log"
87 | assert html =~ log.endpoint
88 | end
89 |
90 | test "updates log within modal", %{conn: conn, log: log} do
91 | {:ok, show_live, _html} = live(conn, ~p"/logs/#{log}")
92 |
93 | assert show_live |> element("a", "Edit") |> render_click() =~
94 | "Edit Log"
95 |
96 | assert_patch(show_live, ~p"/logs/#{log}/show/edit")
97 |
98 | assert show_live
99 | |> form("#log-form", log: @invalid_attrs)
100 | |> render_change() =~ "can't be blank"
101 |
102 | assert show_live
103 | |> form("#log-form", log: @update_attrs)
104 | |> render_submit()
105 |
106 | assert_patch(show_live, ~p"/logs/#{log}")
107 |
108 | html = render(show_live)
109 | assert html =~ "Log updated successfully"
110 | assert html =~ "some updated endpoint"
111 | end
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/live/message_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.MessageLiveTest do
2 | use Phoenix00Web.ConnCase
3 |
4 | import Phoenix.LiveViewTest
5 | import Phoenix00.MessagesFixtures
6 |
7 | @create_attrs %{status: :pending}
8 | @update_attrs %{status: :sent}
9 | @invalid_attrs %{status: nil}
10 |
11 | defp create_message(_) do
12 | message = message_fixture()
13 | %{message: message}
14 | end
15 |
16 | describe "Index" do
17 | setup [:create_message]
18 |
19 | test "lists all messages", %{conn: conn} do
20 | {:ok, _index_live, html} = live(conn, ~p"/messages")
21 |
22 | assert html =~ "Listing Messages"
23 | end
24 |
25 | test "saves new message", %{conn: conn} do
26 | {:ok, index_live, _html} = live(conn, ~p"/messages")
27 |
28 | assert index_live |> element("a", "New Message") |> render_click() =~
29 | "New Message"
30 |
31 | assert_patch(index_live, ~p"/messages/new")
32 |
33 | assert index_live
34 | |> form("#message-form", message: @invalid_attrs)
35 | |> render_change() =~ "can't be blank"
36 |
37 | assert index_live
38 | |> form("#message-form", message: @create_attrs)
39 | |> render_submit()
40 |
41 | assert_patch(index_live, ~p"/messages")
42 |
43 | html = render(index_live)
44 | assert html =~ "Message created successfully"
45 | end
46 |
47 | test "updates message in listing", %{conn: conn, message: message} do
48 | {:ok, index_live, _html} = live(conn, ~p"/messages")
49 |
50 | assert index_live |> element("#messages-#{message.id} a", "Edit") |> render_click() =~
51 | "Edit Message"
52 |
53 | assert_patch(index_live, ~p"/messages/#{message}/edit")
54 |
55 | assert index_live
56 | |> form("#message-form", message: @invalid_attrs)
57 | |> render_change() =~ "can't be blank"
58 |
59 | assert index_live
60 | |> form("#message-form", message: @update_attrs)
61 | |> render_submit()
62 |
63 | assert_patch(index_live, ~p"/messages")
64 |
65 | html = render(index_live)
66 | assert html =~ "Message updated successfully"
67 | end
68 |
69 | test "deletes message in listing", %{conn: conn, message: message} do
70 | {:ok, index_live, _html} = live(conn, ~p"/messages")
71 |
72 | assert index_live |> element("#messages-#{message.id} a", "Delete") |> render_click()
73 | refute has_element?(index_live, "#messages-#{message.id}")
74 | end
75 | end
76 |
77 | describe "Show" do
78 | setup [:create_message]
79 |
80 | test "displays message", %{conn: conn, message: message} do
81 | {:ok, _show_live, html} = live(conn, ~p"/messages/#{message}")
82 |
83 | assert html =~ "Show Message"
84 | end
85 |
86 | test "updates message within modal", %{conn: conn, message: message} do
87 | {:ok, show_live, _html} = live(conn, ~p"/messages/#{message}")
88 |
89 | assert show_live |> element("a", "Edit") |> render_click() =~
90 | "Edit Message"
91 |
92 | assert_patch(show_live, ~p"/messages/#{message}/show/edit")
93 |
94 | assert show_live
95 | |> form("#message-form", message: @invalid_attrs)
96 | |> render_change() =~ "can't be blank"
97 |
98 | assert show_live
99 | |> form("#message-form", message: @update_attrs)
100 | |> render_submit()
101 |
102 | assert_patch(show_live, ~p"/messages/#{message}")
103 |
104 | html = render(show_live)
105 | assert html =~ "Message updated successfully"
106 | end
107 | end
108 | end
109 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/live/user_confirmation_instructions_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserConfirmationInstructionsLiveTest do
2 | use Phoenix00Web.ConnCase, async: true
3 |
4 | import Phoenix.LiveViewTest
5 | import Phoenix00.AccountsFixtures
6 |
7 | alias Phoenix00.Accounts
8 | alias Phoenix00.Repo
9 |
10 | setup do
11 | %{user: user_fixture()}
12 | end
13 |
14 | describe "Resend confirmation" do
15 | test "renders the resend confirmation page", %{conn: conn} do
16 | {:ok, _lv, html} = live(conn, ~p"/users/confirm")
17 | assert html =~ "Resend confirmation instructions"
18 | end
19 |
20 | test "sends a new confirmation token", %{conn: conn, user: user} do
21 | {:ok, lv, _html} = live(conn, ~p"/users/confirm")
22 |
23 | {:ok, conn} =
24 | lv
25 | |> form("#resend_confirmation_form", user: %{email: user.email})
26 | |> render_submit()
27 | |> follow_redirect(conn, ~p"/")
28 |
29 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
30 | "If your email is in our system"
31 |
32 | assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm"
33 | end
34 |
35 | test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do
36 | Repo.update!(Accounts.User.confirm_changeset(user))
37 |
38 | {:ok, lv, _html} = live(conn, ~p"/users/confirm")
39 |
40 | {:ok, conn} =
41 | lv
42 | |> form("#resend_confirmation_form", user: %{email: user.email})
43 | |> render_submit()
44 | |> follow_redirect(conn, ~p"/")
45 |
46 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
47 | "If your email is in our system"
48 |
49 | refute Repo.get_by(Accounts.UserToken, user_id: user.id)
50 | end
51 |
52 | test "does not send confirmation token if email is invalid", %{conn: conn} do
53 | {:ok, lv, _html} = live(conn, ~p"/users/confirm")
54 |
55 | {:ok, conn} =
56 | lv
57 | |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"})
58 | |> render_submit()
59 | |> follow_redirect(conn, ~p"/")
60 |
61 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
62 | "If your email is in our system"
63 |
64 | assert Repo.all(Accounts.UserToken) == []
65 | end
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/live/user_confirmation_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserConfirmationLiveTest do
2 | use Phoenix00Web.ConnCase, async: true
3 |
4 | import Phoenix.LiveViewTest
5 | import Phoenix00.AccountsFixtures
6 |
7 | alias Phoenix00.Accounts
8 | alias Phoenix00.Repo
9 |
10 | setup do
11 | %{user: user_fixture()}
12 | end
13 |
14 | describe "Confirm user" do
15 | test "renders confirmation page", %{conn: conn} do
16 | {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token")
17 | assert html =~ "Confirm Account"
18 | end
19 |
20 | test "confirms the given token once", %{conn: conn, user: user} do
21 | token =
22 | extract_user_token(fn url ->
23 | Accounts.deliver_user_confirmation_instructions(user, url)
24 | end)
25 |
26 | {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}")
27 |
28 | result =
29 | lv
30 | |> form("#confirmation_form")
31 | |> render_submit()
32 | |> follow_redirect(conn, "/")
33 |
34 | assert {:ok, conn} = result
35 |
36 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
37 | "User confirmed successfully"
38 |
39 | assert Accounts.get_user!(user.id).confirmed_at
40 | refute get_session(conn, :user_token)
41 | assert Repo.all(Accounts.UserToken) == []
42 |
43 | # when not logged in
44 | {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}")
45 |
46 | result =
47 | lv
48 | |> form("#confirmation_form")
49 | |> render_submit()
50 | |> follow_redirect(conn, "/")
51 |
52 | assert {:ok, conn} = result
53 |
54 | assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
55 | "User confirmation link is invalid or it has expired"
56 |
57 | # when logged in
58 | conn =
59 | build_conn()
60 | |> log_in_user(user)
61 |
62 | {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}")
63 |
64 | result =
65 | lv
66 | |> form("#confirmation_form")
67 | |> render_submit()
68 | |> follow_redirect(conn, "/")
69 |
70 | assert {:ok, conn} = result
71 | refute Phoenix.Flash.get(conn.assigns.flash, :error)
72 | end
73 |
74 | test "does not confirm email with invalid token", %{conn: conn, user: user} do
75 | {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token")
76 |
77 | {:ok, conn} =
78 | lv
79 | |> form("#confirmation_form")
80 | |> render_submit()
81 | |> follow_redirect(conn, ~p"/")
82 |
83 | assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
84 | "User confirmation link is invalid or it has expired"
85 |
86 | refute Accounts.get_user!(user.id).confirmed_at
87 | end
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/live/user_forgot_password_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserForgotPasswordLiveTest do
2 | use Phoenix00Web.ConnCase, async: true
3 |
4 | import Phoenix.LiveViewTest
5 | import Phoenix00.AccountsFixtures
6 |
7 | alias Phoenix00.Accounts
8 | alias Phoenix00.Repo
9 |
10 | describe "Forgot password page" do
11 | test "renders email page", %{conn: conn} do
12 | {:ok, lv, html} = live(conn, ~p"/users/reset_password")
13 |
14 | assert html =~ "Forgot your password?"
15 | assert has_element?(lv, ~s|a[href="#{~p"/users/register"}"]|, "Register")
16 | assert has_element?(lv, ~s|a[href="#{~p"/users/log_in"}"]|, "Log in")
17 | end
18 |
19 | test "redirects if already logged in", %{conn: conn} do
20 | result =
21 | conn
22 | |> log_in_user(user_fixture())
23 | |> live(~p"/users/reset_password")
24 | |> follow_redirect(conn, ~p"/")
25 |
26 | assert {:ok, _conn} = result
27 | end
28 | end
29 |
30 | describe "Reset link" do
31 | setup do
32 | %{user: user_fixture()}
33 | end
34 |
35 | test "sends a new reset password token", %{conn: conn, user: user} do
36 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password")
37 |
38 | {:ok, conn} =
39 | lv
40 | |> form("#reset_password_form", user: %{"email" => user.email})
41 | |> render_submit()
42 | |> follow_redirect(conn, "/")
43 |
44 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
45 |
46 | assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context ==
47 | "reset_password"
48 | end
49 |
50 | test "does not send reset password token if email is invalid", %{conn: conn} do
51 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password")
52 |
53 | {:ok, conn} =
54 | lv
55 | |> form("#reset_password_form", user: %{"email" => "unknown@example.com"})
56 | |> render_submit()
57 | |> follow_redirect(conn, "/")
58 |
59 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
60 | assert Repo.all(Accounts.UserToken) == []
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/live/user_login_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserLoginLiveTest do
2 | use Phoenix00Web.ConnCase, async: true
3 |
4 | import Phoenix.LiveViewTest
5 | import Phoenix00.AccountsFixtures
6 |
7 | describe "Log in page" do
8 | test "renders log in page", %{conn: conn} do
9 | {:ok, _lv, html} = live(conn, ~p"/users/log_in")
10 |
11 | assert html =~ "Log in"
12 | assert html =~ "Register"
13 | assert html =~ "Forgot your password?"
14 | end
15 |
16 | test "redirects if already logged in", %{conn: conn} do
17 | result =
18 | conn
19 | |> log_in_user(user_fixture())
20 | |> live(~p"/users/log_in")
21 | |> follow_redirect(conn, "/")
22 |
23 | assert {:ok, _conn} = result
24 | end
25 | end
26 |
27 | describe "user login" do
28 | test "redirects if user login with valid credentials", %{conn: conn} do
29 | password = "123456789abcd"
30 | user = user_fixture(%{password: password})
31 |
32 | {:ok, lv, _html} = live(conn, ~p"/users/log_in")
33 |
34 | form =
35 | form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true})
36 |
37 | conn = submit_form(form, conn)
38 |
39 | assert redirected_to(conn) == ~p"/"
40 | end
41 |
42 | test "redirects to login page with a flash error if there are no valid credentials", %{
43 | conn: conn
44 | } do
45 | {:ok, lv, _html} = live(conn, ~p"/users/log_in")
46 |
47 | form =
48 | form(lv, "#login_form",
49 | user: %{email: "test@email.com", password: "123456", remember_me: true}
50 | )
51 |
52 | conn = submit_form(form, conn)
53 |
54 | assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password"
55 |
56 | assert redirected_to(conn) == "/users/log_in"
57 | end
58 | end
59 |
60 | describe "login navigation" do
61 | test "redirects to registration page when the Register button is clicked", %{conn: conn} do
62 | {:ok, lv, _html} = live(conn, ~p"/users/log_in")
63 |
64 | {:ok, _login_live, login_html} =
65 | lv
66 | |> element(~s|main a:fl-contains("Sign up")|)
67 | |> render_click()
68 | |> follow_redirect(conn, ~p"/users/register")
69 |
70 | assert login_html =~ "Register"
71 | end
72 |
73 | test "redirects to forgot password page when the Forgot Password button is clicked", %{
74 | conn: conn
75 | } do
76 | {:ok, lv, _html} = live(conn, ~p"/users/log_in")
77 |
78 | {:ok, conn} =
79 | lv
80 | |> element(~s|main a:fl-contains("Forgot your password?")|)
81 | |> render_click()
82 | |> follow_redirect(conn, ~p"/users/reset_password")
83 |
84 | assert conn.resp_body =~ "Forgot your password?"
85 | end
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/live/user_registration_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserRegistrationLiveTest do
2 | use Phoenix00Web.ConnCase, async: true
3 |
4 | import Phoenix.LiveViewTest
5 | import Phoenix00.AccountsFixtures
6 |
7 | describe "Registration page" do
8 | test "renders registration page", %{conn: conn} do
9 | {:ok, _lv, html} = live(conn, ~p"/users/register")
10 |
11 | assert html =~ "Register"
12 | assert html =~ "Log in"
13 | end
14 |
15 | test "redirects if already logged in", %{conn: conn} do
16 | result =
17 | conn
18 | |> log_in_user(user_fixture())
19 | |> live(~p"/users/register")
20 | |> follow_redirect(conn, "/")
21 |
22 | assert {:ok, _conn} = result
23 | end
24 |
25 | test "renders errors for invalid data", %{conn: conn} do
26 | {:ok, lv, _html} = live(conn, ~p"/users/register")
27 |
28 | result =
29 | lv
30 | |> element("#registration_form")
31 | |> render_change(user: %{"email" => "with spaces", "password" => "too short"})
32 |
33 | assert result =~ "Register"
34 | assert result =~ "must have the @ sign and no spaces"
35 | assert result =~ "should be at least 12 character"
36 | end
37 | end
38 |
39 | describe "register user" do
40 | test "creates account and logs the user in", %{conn: conn} do
41 | {:ok, lv, _html} = live(conn, ~p"/users/register")
42 |
43 | email = unique_user_email()
44 | form = form(lv, "#registration_form", user: valid_user_attributes(email: email))
45 | render_submit(form)
46 | conn = follow_trigger_action(form, conn)
47 |
48 | assert redirected_to(conn) == ~p"/"
49 |
50 | # Now do a logged in request and assert on the menu
51 | conn = get(conn, "/")
52 | response = html_response(conn, 200)
53 | assert response =~ email
54 | assert response =~ "Settings"
55 | assert response =~ "Log out"
56 | end
57 |
58 | test "renders errors for duplicated email", %{conn: conn} do
59 | {:ok, lv, _html} = live(conn, ~p"/users/register")
60 |
61 | user = user_fixture(%{email: "test@email.com"})
62 |
63 | result =
64 | lv
65 | |> form("#registration_form",
66 | user: %{"email" => user.email, "password" => "valid_password"}
67 | )
68 | |> render_submit()
69 |
70 | assert result =~ "has already been taken"
71 | end
72 | end
73 |
74 | describe "registration navigation" do
75 | test "redirects to login page when the Log in button is clicked", %{conn: conn} do
76 | {:ok, lv, _html} = live(conn, ~p"/users/register")
77 |
78 | {:ok, _login_live, login_html} =
79 | lv
80 | |> element(~s|main a:fl-contains("Log in")|)
81 | |> render_click()
82 | |> follow_redirect(conn, ~p"/users/log_in")
83 |
84 | assert login_html =~ "Log in"
85 | end
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/test/phoenix_00_web/live/user_reset_password_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.UserResetPasswordLiveTest do
2 | use Phoenix00Web.ConnCase, async: true
3 |
4 | import Phoenix.LiveViewTest
5 | import Phoenix00.AccountsFixtures
6 |
7 | alias Phoenix00.Accounts
8 |
9 | setup do
10 | user = user_fixture()
11 |
12 | token =
13 | extract_user_token(fn url ->
14 | Accounts.deliver_user_reset_password_instructions(user, url)
15 | end)
16 |
17 | %{token: token, user: user}
18 | end
19 |
20 | describe "Reset password page" do
21 | test "renders reset password with valid token", %{conn: conn, token: token} do
22 | {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}")
23 |
24 | assert html =~ "Reset Password"
25 | end
26 |
27 | test "does not render reset password with invalid token", %{conn: conn} do
28 | {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid")
29 |
30 | assert to == %{
31 | flash: %{"error" => "Reset password link is invalid or it has expired."},
32 | to: ~p"/"
33 | }
34 | end
35 |
36 | test "renders errors for invalid data", %{conn: conn, token: token} do
37 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
38 |
39 | result =
40 | lv
41 | |> element("#reset_password_form")
42 | |> render_change(
43 | user: %{"password" => "secret12", "password_confirmation" => "secret123456"}
44 | )
45 |
46 | assert result =~ "should be at least 12 character"
47 | assert result =~ "does not match password"
48 | end
49 | end
50 |
51 | describe "Reset Password" do
52 | test "resets password once", %{conn: conn, token: token, user: user} do
53 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
54 |
55 | {:ok, conn} =
56 | lv
57 | |> form("#reset_password_form",
58 | user: %{
59 | "password" => "new valid password",
60 | "password_confirmation" => "new valid password"
61 | }
62 | )
63 | |> render_submit()
64 | |> follow_redirect(conn, ~p"/users/log_in")
65 |
66 | refute get_session(conn, :user_token)
67 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully"
68 | assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
69 | end
70 |
71 | test "does not reset password on invalid data", %{conn: conn, token: token} do
72 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
73 |
74 | result =
75 | lv
76 | |> form("#reset_password_form",
77 | user: %{
78 | "password" => "too short",
79 | "password_confirmation" => "does not match"
80 | }
81 | )
82 | |> render_submit()
83 |
84 | assert result =~ "Reset Password"
85 | assert result =~ "should be at least 12 character(s)"
86 | assert result =~ "does not match password"
87 | end
88 | end
89 |
90 | describe "Reset password navigation" do
91 | test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do
92 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
93 |
94 | {:ok, conn} =
95 | lv
96 | |> element(~s|main a:fl-contains("Log in")|)
97 | |> render_click()
98 | |> follow_redirect(conn, ~p"/users/log_in")
99 |
100 | assert conn.resp_body =~ "Log in"
101 | end
102 |
103 | test "redirects to registration page when the Register button is clicked", %{
104 | conn: conn,
105 | token: token
106 | } do
107 | {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
108 |
109 | {:ok, conn} =
110 | lv
111 | |> element(~s|main a:fl-contains("Register")|)
112 | |> render_click()
113 | |> follow_redirect(conn, ~p"/users/register")
114 |
115 | assert conn.resp_body =~ "Register"
116 | end
117 | end
118 | end
119 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00Web.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use Phoenix00Web.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # The default endpoint for testing
23 | @endpoint Phoenix00Web.Endpoint
24 |
25 | use Phoenix00Web, :verified_routes
26 |
27 | # Import conveniences for testing with connections
28 | import Plug.Conn
29 | import Phoenix.ConnTest
30 | import Phoenix00Web.ConnCase
31 | end
32 | end
33 |
34 | setup tags do
35 | Phoenix00.DataCase.setup_sandbox(tags)
36 | {:ok, conn: Phoenix.ConnTest.build_conn()}
37 | end
38 |
39 | @doc """
40 | Setup helper that registers and logs in users.
41 |
42 | setup :register_and_log_in_user
43 |
44 | It stores an updated connection and a registered user in the
45 | test context.
46 | """
47 | def register_and_log_in_user(%{conn: conn}) do
48 | user = Phoenix00.AccountsFixtures.user_fixture()
49 | %{conn: log_in_user(conn, user), user: user}
50 | end
51 |
52 | @doc """
53 | Logs the given `user` into the `conn`.
54 |
55 | It returns an updated `conn`.
56 | """
57 | def log_in_user(conn, user) do
58 | token = Phoenix00.Accounts.generate_user_session_token(user)
59 |
60 | conn
61 | |> Phoenix.ConnTest.init_test_session(%{})
62 | |> Plug.Conn.put_session(:user_token, token)
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | we enable the SQL sandbox, so changes done to the database
11 | are reverted at the end of every test. If you are using
12 | PostgreSQL, you can even run database tests asynchronously
13 | by setting `use Phoenix00.DataCase, async: true`, although
14 | this option is not recommended for other databases.
15 | """
16 |
17 | use ExUnit.CaseTemplate
18 |
19 | using do
20 | quote do
21 | alias Phoenix00.Repo
22 |
23 | import Ecto
24 | import Ecto.Changeset
25 | import Ecto.Query
26 | import Phoenix00.DataCase
27 | end
28 | end
29 |
30 | setup tags do
31 | Phoenix00.DataCase.setup_sandbox(tags)
32 | :ok
33 | end
34 |
35 | @doc """
36 | Sets up the sandbox based on the test tags.
37 | """
38 | def setup_sandbox(tags) do
39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Phoenix00.Repo, shared: not tags[:async])
40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
41 | end
42 |
43 | @doc """
44 | A helper that transforms changeset errors into a map of messages.
45 |
46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
47 | assert "password is too short" in errors_on(changeset).password
48 | assert %{password: ["password is too short"]} = errors_on(changeset)
49 |
50 | """
51 | def errors_on(changeset) do
52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
53 | Regex.replace(~r"%{(\w+)}", message, fn _, key ->
54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
55 | end)
56 | end)
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/test/support/fixtures/accounts_fixtures.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.AccountsFixtures do
2 | @moduledoc """
3 | This module defines test helpers for creating
4 | entities via the `Phoenix00.Accounts` context.
5 | """
6 |
7 | def unique_user_email, do: "user#{System.unique_integer()}@example.com"
8 | def valid_user_password, do: "hello world!"
9 |
10 | def valid_user_attributes(attrs \\ %{}) do
11 | Enum.into(attrs, %{
12 | email: unique_user_email(),
13 | password: valid_user_password()
14 | })
15 | end
16 |
17 | def user_fixture(attrs \\ %{}) do
18 | {:ok, user} =
19 | attrs
20 | |> valid_user_attributes()
21 | |> Phoenix00.Accounts.register_user()
22 |
23 | user
24 | end
25 |
26 | def extract_user_token(fun) do
27 | {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
28 | [_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
29 | token
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/test/support/fixtures/contacts_fixtures.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.ContactsFixtures do
2 | @moduledoc """
3 | This module defines test helpers for creating
4 | entities via the `Phoenix00.Contacts` context.
5 | """
6 |
7 | @doc """
8 | Generate a recipient.
9 | """
10 | def recipient_fixture(attrs \\ %{}) do
11 | {:ok, recipient} =
12 | attrs
13 | |> Enum.into(%{
14 | destination: "some destination"
15 | })
16 | |> Phoenix00.Contacts.create_recipient()
17 |
18 | recipient
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/test/support/fixtures/events_fixtures.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.EventsFixtures do
2 | @moduledoc """
3 | This module defines test helpers for creating
4 | entities via the `Phoenix00.Events` context.
5 | """
6 |
7 | @doc """
8 | Generate a event.
9 | """
10 | def event_fixture(attrs \\ %{}) do
11 | {:ok, event} =
12 | attrs
13 | |> Enum.into(%{
14 | status: "some status"
15 | })
16 | |> Phoenix00.Events.create_event()
17 |
18 | event
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/test/support/fixtures/logs_fixtures.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.LogsFixtures do
2 | @moduledoc """
3 | This module defines test helpers for creating
4 | entities via the `Phoenix00.Logs` context.
5 | """
6 |
7 | @doc """
8 | Generate a log.
9 | """
10 | def log_fixture(attrs \\ %{}) do
11 | {:ok, log} =
12 | attrs
13 | |> Enum.into(%{
14 | endpoint: "some endpoint",
15 | ke_name: "some ke_name",
16 | method: :get,
17 | request: %{},
18 | response: %{},
19 | status: 42
20 | })
21 | |> Phoenix00.Logs.create_log()
22 |
23 | log
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/test/support/fixtures/messages_fixtures.ex:
--------------------------------------------------------------------------------
1 | defmodule Phoenix00.MessagesFixtures do
2 | @moduledoc """
3 | This module defines test helpers for creating
4 | entities via the `Phoenix00.Messages` context.
5 | """
6 |
7 | @doc """
8 | Generate a unique email email_id.
9 | """
10 | def unique_email_email_id, do: "some email_id#{System.unique_integer([:positive])}"
11 |
12 | @doc """
13 | Generate a email.
14 | """
15 | def email_fixture(attrs \\ %{}) do
16 | {:ok, email} =
17 | attrs
18 | |> Enum.into(%{
19 | aws_message_id: "some aws_message_id",
20 | email_id: unique_email_email_id(),
21 | from: "some from",
22 | status: :pending,
23 | to: "some to"
24 | })
25 | |> Phoenix00.Messages.create_email()
26 |
27 | email
28 | end
29 |
30 | @doc """
31 | Generate a message.
32 | """
33 | def message_fixture(attrs \\ %{}) do
34 | {:ok, message} =
35 | attrs
36 | |> Enum.into(%{
37 | status: :pending
38 | })
39 | |> Phoenix00.Messages.create_message()
40 |
41 | message
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 | Ecto.Adapters.SQL.Sandbox.mode(Phoenix00.Repo, :manual)
3 |
--------------------------------------------------------------------------------