commit 9ece31244243c9a355949585b8adda0128dc75bc Author: Kevin Sivic Date: Fri Nov 28 21:30:50 2025 -0500 Initial commit: RateMyClient™ Phoenix application Features: - User registration and authentication with email/password - Admin login with username-based authentication (separate from regular users) - Review system for contractors to rate clients - Star rating system with review forms - Client identification with private data protection - Contractor registration with document verification - Admin dashboard for review management - Contact form (demo, non-functional) - Responsive navigation with DaisyUI components - Docker Compose setup for production deployment - PostgreSQL database with Ecto migrations - High Vis color scheme (dark background with safety orange/green) Admin credentials: username: admin, password: admin123 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0869f52 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(iex -S mix run -e 'MyFirstElixirVibeCode.Accounts.create_user(%{username: \"\"admin\"\", password: \"\"admin123\"\", is_admin: true}) |> IO.inspect()')" + ], + "deny": [], + "ask": [] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e48eb19 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Git +.git +.gitignore + +# Build artifacts +_build +deps +*.ez + +# Environment files +.env +.env.* + +# Dependencies +node_modules +assets/node_modules + +# Database +*.db +*.sqlite3 + +# Logs +*.log + +# IDE +.idea +.vscode +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Phoenix artifacts +priv/static/assets + +# Docker +docker-compose.yml +Dockerfile +.dockerignore diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..ef8840c --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,6 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81f5da4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +my_first_elixir_vibe_code-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..1ba335b --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang 28.1 +elixir 1.19.0-rc.0-otp-28 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6f52c21 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,334 @@ +This is a web application written using the Phoenix web framework. + +## Project guidelines + +- Use `mix precommit` alias when you are done with all changes and fix any pending issues +- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps + +### Phoenix v1.8 guidelines + +- **Always** begin your LiveView templates with `` which wraps all inner content +- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again +- Anytime you run into errors with no `current_scope` assign: + - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `` + - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed +- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module +- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar +- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will will save steps and prevent errors +- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your +custom classes must fully style the input + +### JS and CSS guidelines + +- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces. +- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`: + + @import "tailwindcss" source(none); + @source "../css"; + @source "../js"; + @source "../../lib/my_app_web"; + +- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new` +- **Never** use `@apply` when writing raw css +- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design +- Out of the box **only the app.js and app.css bundles are supported** + - You cannot reference an external vendor'd script `src` or link `href` in the layouts + - You must import the vendor deps into app.js and app.css to use them + - **Never write inline tags within templates** + +### UI/UX & design guidelines + +- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles +- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions) +- Ensure **clean typography, spacing, and layout balance** for a refined, premium look +- Focus on **delightful details** like hover effects, loading states, and smooth page transitions + + + + + +## Elixir guidelines + +- Elixir lists **do not support index based access via the access syntax** + + **Never do this (invalid)**: + + i = 0 + mylist = ["blue", "green"] + mylist[i] + + Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: + + i = 0 + mylist = ["blue", "green"] + Enum.at(mylist, i) + +- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc + you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: + + # INVALID: we are rebinding inside the `if` and the result never gets assigned + if connected?(socket) do + socket = assign(socket, :val, val) + end + + # VALID: we rebind the result of the `if` to a new variable + socket = + if connected?(socket) do + assign(socket, :val, val) + end + +- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors +- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets +- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) +- Don't use `String.to_atom/1` on user input (memory leak risk) +- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards +- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` +- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option + +## Mix guidelines + +- Read the docs and options before using tasks (by using `mix help task_name`) +- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` +- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason + + + +## Phoenix guidelines + +- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. + +- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: + + scope "/admin", AppWeb.Admin do + pipe_through :browser + + live "/users", UserLive, :index + end + + the UserLive route would point to the `AppWeb.Admin.UserLive` module + +- `Phoenix.View` no longer is needed or included with Phoenix, don't use it + + + +## Ecto Guidelines + +- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` +- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` +- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` +- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed +- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields +- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct + + + +## Phoenix HTML guidelines + +- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` +- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated +- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` +- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`) +- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) + +- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. + + **Never do this (invalid)**: + + <%= if condition do %> + ... + <% else if other_condition %> + ... + <% end %> + + Instead **always** do this: + + <%= cond do %> + <% condition -> %> + ... + <% condition2 -> %> + ... + <% true -> %> + ... + <% end %> + +- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
+
+      
+        let obj = {key: "val"}
+      
+
+  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
+
+- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
+
+      Text
+
+  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
+
+  and **never** do this, since it's invalid (note the missing `[` and `]`):
+
+       ...
+      => Raises compile syntax error on invalid HEEx attr syntax
+
+- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
+- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
+- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
+
+  **Always** do this:
+
+      
+ {@my_assign} + <%= if @some_block_condition do %> + {@another_assign} + <% end %> +
+ + and **Never** do this – the program will terminate with a syntax error: + + <%!-- THIS IS INVALID NEVER EVER DO THIS --%> +
+ {if @invalid_block_construct do} + {end} +
+ + + +## Phoenix LiveView guidelines + +- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews +- **Avoid LiveComponent's** unless you have a strong, specific need for them +- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive` +- Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute +- **Never** write embedded ` + + + +
+ {@inner_content} + + diff --git a/lib/my_first_elixir_vibe_code_web/controllers/error_html.ex b/lib/my_first_elixir_vibe_code_web/controllers/error_html.ex new file mode 100644 index 0000000..00f290e --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule MyFirstElixirVibeCodeWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use MyFirstElixirVibeCodeWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/my_first_elixir_vibe_code_web/controllers/error_html/404.html.heex + # * lib/my_first_elixir_vibe_code_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/my_first_elixir_vibe_code_web/controllers/error_json.ex b/lib/my_first_elixir_vibe_code_web/controllers/error_json.ex new file mode 100644 index 0000000..6929ab9 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule MyFirstElixirVibeCodeWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/lib/my_first_elixir_vibe_code_web/controllers/page_controller.ex b/lib/my_first_elixir_vibe_code_web/controllers/page_controller.ex new file mode 100644 index 0000000..8ab7743 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/controllers/page_controller.ex @@ -0,0 +1,7 @@ +defmodule MyFirstElixirVibeCodeWeb.PageController do + use MyFirstElixirVibeCodeWeb, :controller + + def home(conn, _params) do + render(conn, :home) + end +end diff --git a/lib/my_first_elixir_vibe_code_web/controllers/page_html.ex b/lib/my_first_elixir_vibe_code_web/controllers/page_html.ex new file mode 100644 index 0000000..ff537bc --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule MyFirstElixirVibeCodeWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use MyFirstElixirVibeCodeWeb, :html + + embed_templates "page_html/*" +end diff --git a/lib/my_first_elixir_vibe_code_web/controllers/page_html/home.html.heex b/lib/my_first_elixir_vibe_code_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..0ffc330 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/controllers/page_html/home.html.heex @@ -0,0 +1,112 @@ + +
+
+
+

+ RateMyClient +

+

+ Where Contractors Review Clients +

+

+ Building better business relationships, one honest review at a time. +

+
+ +
+
+
+

+ + + + For Contractors +

+
    +
  • + + Screen potential clients before accepting projects +
  • +
  • + + Identify red flags early - late payments, scope creep, unrealistic expectations +
  • +
  • + + Save time and money by avoiding problematic clients +
  • +
  • + + Build your reputation by leaving professional, constructive reviews +
  • +
  • + + Connect with quality clients who value your work +
  • +
+
+
+ +
+
+

+ + + + For Clients +

+
    +
  • + + Build a strong reputation as a reliable, professional client +
  • +
  • + + Attract top contractors who want to work with you +
  • +
  • + + Get better bids - contractors offer better rates to proven clients +
  • +
  • + + Gain valuable feedback on how to improve your project management +
  • +
  • + + Stand out from the crowd with verified positive reviews +
  • +
+
+
+
+ +
+
+

Why Two-Way Reviews Matter

+

+ Traditional review sites only tell half the story. RateMyClient creates transparency and accountability + on both sides of the contractor-client relationship. When both parties know they'll be reviewed, + everyone brings their best to the table. The result? Better projects, stronger relationships, + and a healthier marketplace for everyone. +

+
+
+ + +
+
diff --git a/lib/my_first_elixir_vibe_code_web/controllers/user_session_controller.ex b/lib/my_first_elixir_vibe_code_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..d287656 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/controllers/user_session_controller.ex @@ -0,0 +1,36 @@ +defmodule MyFirstElixirVibeCodeWeb.UserSessionController do + use MyFirstElixirVibeCodeWeb, :controller + + alias MyFirstElixirVibeCode.Accounts + alias MyFirstElixirVibeCodeWeb.UserAuth + + def create(conn, %{"email" => email, "password" => password}) do + case Accounts.get_user_by_email_and_password(email, password) do + nil -> + conn + |> put_flash(:error, "Invalid email or password") + |> redirect(to: ~p"/login") + + user -> + UserAuth.log_in_user(conn, user) + end + end + + def create_admin(conn, %{"username" => username, "password" => password}) do + case Accounts.get_user_by_username_and_password(username, password) do + nil -> + conn + |> put_flash(:error, "Invalid username or password") + |> redirect(to: ~p"/admin/login") + + user -> + UserAuth.log_in_user(conn, user) + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/lib/my_first_elixir_vibe_code_web/endpoint.ex b/lib/my_first_elixir_vibe_code_web/endpoint.ex new file mode 100644 index 0000000..3352e49 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/endpoint.ex @@ -0,0 +1,54 @@ +defmodule MyFirstElixirVibeCodeWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :my_first_elixir_vibe_code + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_my_first_elixir_vibe_code_key", + signing_salt: "6MX1Nc4D", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # When code reloading is disabled (e.g., in production), + # the `gzip` option is enabled to serve compressed + # static files generated by running `phx.digest`. + plug Plug.Static, + at: "/", + from: :my_first_elixir_vibe_code, + gzip: not code_reloading?, + only: MyFirstElixirVibeCodeWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :my_first_elixir_vibe_code + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug MyFirstElixirVibeCodeWeb.Router +end diff --git a/lib/my_first_elixir_vibe_code_web/gettext.ex b/lib/my_first_elixir_vibe_code_web/gettext.ex new file mode 100644 index 0000000..b19ebd1 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/gettext.ex @@ -0,0 +1,25 @@ +defmodule MyFirstElixirVibeCodeWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations + that you can use in your application. To use this Gettext backend module, + call `use Gettext` and pass it as an option: + + use Gettext, backend: MyFirstElixirVibeCodeWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext.Backend, otp_app: :my_first_elixir_vibe_code +end diff --git a/lib/my_first_elixir_vibe_code_web/live/admin_dashboard_live.ex b/lib/my_first_elixir_vibe_code_web/live/admin_dashboard_live.ex new file mode 100644 index 0000000..a55417c --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/live/admin_dashboard_live.ex @@ -0,0 +1,182 @@ +defmodule MyFirstElixirVibeCodeWeb.AdminDashboardLive do + use MyFirstElixirVibeCodeWeb, :live_view + + alias MyFirstElixirVibeCode.Reviews + + @impl true + def render(assigns) do + ~H""" + +
+
+

Admin Dashboard

+ +
+ + + + <%= if @active_tab == "reviews" do %> +
+ <%= for review <- @reviews do %> +
+
+
+
+

Review Details

+
+

Rating: <%= review.rating %> / 5

+

Title: <%= review.title %>

+

Content: <%= review.content %>

+

Project Type: <%= review.project_type %>

+

+ Submitted: + <%= Calendar.strftime(review.inserted_at, "%Y-%m-%d %H:%M") %> +

+
+
+ +
+

+ Client Information (Private) +

+
+

+ Name: + <%= review.client_first_name %> <%= review.client_last_name %> +

+

Street: <%= review.client_street_address %>

+

+ City: + <%= review.client_city %>, <%= review.client_state %> <%= review.client_zip %> +

+ <%= if review.client_country do %> +

Country: <%= review.client_country %>

+ <% end %> +
+ +
+

Verification Documents:

+ <%= if review.municipal_registration_proof do %> + + View Document + + <% end %> +
+
+
+
+
+ <% end %> +
+ <% end %> + + <%= if @active_tab == "stats" do %> +
+
+
Total Reviews
+
<%= length(@reviews) %>
+
+ +
+
Average Rating
+
<%= @avg_rating %>
+
+ +
+
Unique Clients
+
<%= @unique_clients %>
+
+
+ +
+

Recent Activity

+
+ + + + + + + + + + + <%= for review <- Enum.take(@reviews, 10) do %> + + + + + + + <% end %> + +
DateClientRatingProject Type
<%= Calendar.strftime(review.inserted_at, "%Y-%m-%d") %> + <%= review.client_first_name %> <%= review.client_last_name %> + <%= review.rating %> / 5<%= review.project_type %>
+
+
+ <% end %> +
+
+ """ + end + + @impl true + def mount(_params, _session, socket) do + reviews = Reviews.list_reviews() + + avg_rating = + if Enum.empty?(reviews) do + 0.0 + else + (Enum.sum(Enum.map(reviews, & &1.rating)) / length(reviews)) + |> Float.round(1) + end + + unique_clients = + reviews + |> Enum.map(&{&1.client_first_name, &1.client_last_name}) + |> Enum.uniq() + |> length() + + {:ok, + socket + |> assign(:page_title, "Admin Dashboard") + |> assign(:reviews, reviews) + |> assign(:active_tab, "reviews") + |> assign(:avg_rating, avg_rating) + |> assign(:unique_clients, unique_clients)} + end + + @impl true + def handle_event("switch_tab", %{"tab" => tab}, socket) do + {:noreply, assign(socket, :active_tab, tab)} + end + + @impl true + def handle_event("logout", _params, socket) do + {:noreply, + socket + |> put_flash(:info, "Logged out successfully") + |> push_navigate(to: ~p"/login")} + end +end diff --git a/lib/my_first_elixir_vibe_code_web/live/admin_login_live.ex b/lib/my_first_elixir_vibe_code_web/live/admin_login_live.ex new file mode 100644 index 0000000..23a2707 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/live/admin_login_live.ex @@ -0,0 +1,53 @@ +defmodule MyFirstElixirVibeCodeWeb.AdminLoginLive do + use MyFirstElixirVibeCodeWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + +
+
+
+

Admin Login

+
+ + +
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end +end diff --git a/lib/my_first_elixir_vibe_code_web/live/contact_live.ex b/lib/my_first_elixir_vibe_code_web/live/contact_live.ex new file mode 100644 index 0000000..bd88a4e --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/live/contact_live.ex @@ -0,0 +1,107 @@ +defmodule MyFirstElixirVibeCodeWeb.ContactLive do + use MyFirstElixirVibeCodeWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + +
+
+
+

Contact Us

+

+ Have a question or feedback? We'd love to hear from you! +

+ +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
OR
+ +
+

+ Email us directly at support@ratemyclient.com +

+
+
+
+
+
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_event("submit", params, socket) do + # This is a non-working contact form + # In a real app, you would send an email or save to database here + socket = + socket + |> put_flash(:info, "Thank you for your message! We'll get back to you soon. (Note: This is a demo form and does not actually send messages)") + |> push_navigate(to: ~p"/") + + {:noreply, socket} + end +end diff --git a/lib/my_first_elixir_vibe_code_web/live/contractor_registration_live.ex b/lib/my_first_elixir_vibe_code_web/live/contractor_registration_live.ex new file mode 100644 index 0000000..83bfe4b --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/live/contractor_registration_live.ex @@ -0,0 +1,275 @@ +defmodule MyFirstElixirVibeCodeWeb.ContractorRegistrationLive do + use MyFirstElixirVibeCodeWeb, :live_view + + alias MyFirstElixirVibeCode.Accounts + alias MyFirstElixirVibeCode.Accounts.Contractor + + @impl true + def mount(_params, _session, socket) do + changeset = Accounts.change_contractor_registration(%Contractor{}) + + {:ok, + socket + |> assign(:changeset, changeset) + |> assign(:uploaded_files, []) + |> allow_upload(:municipal_registration, accept: ~w(.pdf .jpg .jpeg .png), max_entries: 1) + |> allow_upload(:insurance_proof, accept: ~w(.pdf .jpg .jpeg .png), max_entries: 1)} + end + + @impl true + def handle_event("validate", %{"contractor" => contractor_params}, socket) do + changeset = + %Contractor{} + |> Accounts.change_contractor_registration(contractor_params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :changeset, changeset)} + end + + @impl true + def handle_event("save", %{"contractor" => contractor_params}, socket) do + # Upload municipal registration + municipal_paths = + consume_uploaded_entries(socket, :municipal_registration, fn %{path: path}, entry -> + dest = Path.join("priv/static/uploads", "#{entry.uuid}.#{ext(entry)}") + File.mkdir_p!(Path.dirname(dest)) + File.cp!(path, dest) + {:ok, "/uploads/#{entry.uuid}.#{ext(entry)}"} + end) + + # Upload insurance proof + insurance_paths = + consume_uploaded_entries(socket, :insurance_proof, fn %{path: path}, entry -> + dest = Path.join("priv/static/uploads", "#{entry.uuid}.#{ext(entry)}") + File.mkdir_p!(Path.dirname(dest)) + File.cp!(path, dest) + {:ok, "/uploads/#{entry.uuid}.#{ext(entry)}"} + end) + + contractor_params = + contractor_params + |> Map.put("municipal_registration_proof", List.first(municipal_paths)) + |> Map.put("insurance_proof", List.first(insurance_paths)) + + case Accounts.register_contractor(contractor_params) do + {:ok, _contractor} -> + {:noreply, + socket + |> put_flash(:info, "Registration submitted successfully! Your application is pending review.") + |> push_navigate(to: ~p"/")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :changeset, changeset)} + end + end + + defp ext(entry) do + [ext | _] = MIME.extensions(entry.client_type) + ext + end + + @impl true + def render(assigns) do + ~H""" +
+
+

Contractor Registration

+

+ Register to start submitting client reviews +

+
+ +
+
+ <.form + :let={f} + for={@changeset} + as={:contractor} + phx-change="validate" + phx-submit="save" + class="space-y-6" + > +
+ <.input field={f[:name]} type="text" label="Full Name" required /> +
+ +
+ <.input field={f[:email]} type="email" label="Email Address" required /> +
+ +
+ <.input field={f[:company_name]} type="text" label="Company Name" required /> +
+ +
Required Documents
+ +
+ + + + + + You must provide proof of at least one municipal registration and current business insurance to complete registration. + +
+ +
+ +
+ + +
+ + <%= for entry <- @uploads.municipal_registration.entries do %> +
+ + + + <%= entry.client_name %> +
+ <% end %> + + <%= for err <- upload_errors(@uploads.municipal_registration) do %> +

<%= error_to_string(err) %>

+ <% end %> +
+ +
+ +
+ + +
+ + <%= for entry <- @uploads.insurance_proof.entries do %> +
+ + + + <%= entry.client_name %> +
+ <% end %> + + <%= for err <- upload_errors(@uploads.insurance_proof) do %> +

<%= error_to_string(err) %>

+ <% end %> +
+ +
+ <.link navigate={~p"/"} class="btn btn-ghost">Cancel + +
+ +
+
+
+ """ + end + + defp error_to_string(:too_large), do: "File is too large" + defp error_to_string(:not_accepted), do: "File type not accepted" + defp error_to_string(:too_many_files), do: "Too many files" +end diff --git a/lib/my_first_elixir_vibe_code_web/live/review_live/form.ex b/lib/my_first_elixir_vibe_code_web/live/review_live/form.ex new file mode 100644 index 0000000..93b6619 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/live/review_live/form.ex @@ -0,0 +1,258 @@ +defmodule MyFirstElixirVibeCodeWeb.ReviewLive.Form do + use MyFirstElixirVibeCodeWeb, :live_view + + alias MyFirstElixirVibeCode.Reviews + alias MyFirstElixirVibeCode.Reviews.Review + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@page_title} + <:subtitle>Use this form to manage review records in your database. + + + <.form for={@form} id="review-form" phx-change="validate" phx-submit="save"> +
+ +
+ + + + + +
+
+ + <.input field={@form[:title]} type="text" label="Title" /> + <.input field={@form[:content]} type="textarea" label="Content" /> + <.input field={@form[:project_type]} type="text" label="Project type" /> + +
Client Information
+ +
+ + + + + This information is private and will not be shown publicly. It can only be searched by other contractors. + +
+ +
+ <.input field={@form[:client_first_name]} type="text" label="First Name" required /> + <.input field={@form[:client_last_name]} type="text" label="Last Name" required /> +
+ + <.input + field={@form[:client_street_address]} + type="text" + label="Street Address" + required + /> + +
+ <.input field={@form[:client_city]} type="text" label="City" required /> + <.input field={@form[:client_state]} type="text" label="State/Province" required /> + <.input field={@form[:client_zip]} type="text" label="ZIP/Postal Code" required /> +
+ + <.input field={@form[:client_country]} type="text" label="Country" /> + +
Required Verification
+ +
+ + + + + + You must provide proof that you are registered in the client's municipality OR that a permit was pulled for this work. + +
+ +
+ +
+ +
+ <%= for entry <- @uploads.verification_proof.entries do %> +
+ ✓ <%= entry.client_name %> +
+ <% end %> +
+ +
+ <.button phx-disable-with="Saving..." variant="primary">Save Review + <.button navigate={return_path(@return_to, @review)}>Cancel +
+ +
+ """ + end + + @impl true + def mount(params, _session, socket) do + {:ok, + socket + |> assign(:return_to, return_to(params["return_to"])) + |> allow_upload(:verification_proof, accept: ~w(.pdf .jpg .jpeg .png), max_entries: 1) + |> apply_action(socket.assigns.live_action, params)} + end + + defp return_to("show"), do: "show" + defp return_to(_), do: "index" + + defp apply_action(socket, :edit, %{"id" => id}) do + review = Reviews.get_review!(id) + + socket + |> assign(:page_title, "Edit Review") + |> assign(:review, review) + |> assign(:form, to_form(Reviews.change_review(review))) + end + + defp apply_action(socket, :new, _params) do + review = %Review{} + + socket + |> assign(:page_title, "New Review") + |> assign(:review, review) + |> assign(:form, to_form(Reviews.change_review(review))) + end + + @impl true + def handle_event("validate", %{"review" => review_params}, socket) do + changeset = Reviews.change_review(socket.assigns.review, review_params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"review" => review_params}, socket) do + save_review(socket, socket.assigns.live_action, review_params) + end + + defp save_review(socket, :edit, review_params) do + review_params = consume_uploads(socket, review_params) + + case Reviews.update_review(socket.assigns.review, review_params) do + {:ok, review} -> + {:noreply, + socket + |> put_flash(:info, "Review updated successfully") + |> push_navigate(to: return_path(socket.assigns.return_to, review))} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_review(socket, :new, review_params) do + review_params = consume_uploads(socket, review_params) + + case Reviews.create_review(review_params) do + {:ok, review} -> + {:noreply, + socket + |> put_flash(:info, "Review created successfully") + |> push_navigate(to: return_path(socket.assigns.return_to, review))} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp consume_uploads(socket, review_params) do + # Upload verification proof (either municipal registration or work permit) + verification_paths = + consume_uploaded_entries(socket, :verification_proof, fn %{path: path}, entry -> + dest = Path.join("priv/static/uploads", "#{entry.uuid}.#{ext(entry)}") + File.mkdir_p!(Path.dirname(dest)) + File.cp!(path, dest) + {:ok, "/uploads/#{entry.uuid}.#{ext(entry)}"} + end) + + # Store to municipal_registration_proof field + review_params + |> maybe_put_upload(:municipal_registration_proof, verification_paths) + end + + defp maybe_put_upload(params, _key, []), do: params + defp maybe_put_upload(params, key, [path | _]), do: Map.put(params, to_string(key), path) + + defp ext(entry) do + [ext | _] = MIME.extensions(entry.client_type) + ext + end + + defp return_path("index", _review), do: ~p"/reviews" + defp return_path("show", review), do: ~p"/reviews/#{review}" +end diff --git a/lib/my_first_elixir_vibe_code_web/live/review_live/index.ex b/lib/my_first_elixir_vibe_code_web/live/review_live/index.ex new file mode 100644 index 0000000..120a92c --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/live/review_live/index.ex @@ -0,0 +1,100 @@ +defmodule MyFirstElixirVibeCodeWeb.ReviewLive.Index do + use MyFirstElixirVibeCodeWeb, :live_view + + alias MyFirstElixirVibeCode.Reviews + + @impl true + def render(assigns) do + ~H""" + + <.header> + Listing Reviews + <:actions> + <.button variant="primary" navigate={~p"/reviews/new"}> + <.icon name="hero-plus" /> New Review + + + + +
+ <.form for={%{}} phx-change="search" phx-submit="search" class="flex gap-4"> +
+ +
+ + +
+ + <.table + id="reviews" + rows={@streams.reviews} + row_click={fn {_id, review} -> JS.navigate(~p"/reviews/#{review}") end} + > + <:col :let={{_id, review}} label="Rating">{review.rating} + <:col :let={{_id, review}} label="Title">{review.title} + <:col :let={{_id, review}} label="Content">{review.content} + <:col :let={{_id, review}} label="Project type">{review.project_type} + <:action :let={{_id, review}}> +
+ <.link navigate={~p"/reviews/#{review}"}>Show +
+ <.link navigate={~p"/reviews/#{review}/edit"}>Edit + + <:action :let={{id, review}}> + <.link + phx-click={JS.push("delete", value: %{id: review.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + +
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Listing Reviews") + |> assign(:search_query, "") + |> stream(:reviews, list_reviews(""))} + end + + @impl true + def handle_event("search", %{"search_query" => query}, socket) do + {:noreply, + socket + |> assign(:search_query, query) + |> stream(:reviews, list_reviews(query), reset: true)} + end + + @impl true + def handle_event("clear_search", _params, socket) do + {:noreply, + socket + |> assign(:search_query, "") + |> stream(:reviews, list_reviews(""), reset: true)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + review = Reviews.get_review!(id) + {:ok, _} = Reviews.delete_review(review) + + {:noreply, stream_delete(socket, :reviews, review)} + end + + defp list_reviews(search_query) do + Reviews.search_reviews(search_query) + end +end diff --git a/lib/my_first_elixir_vibe_code_web/live/review_live/show.ex b/lib/my_first_elixir_vibe_code_web/live/review_live/show.ex new file mode 100644 index 0000000..aff1643 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/live/review_live/show.ex @@ -0,0 +1,40 @@ +defmodule MyFirstElixirVibeCodeWeb.ReviewLive.Show do + use MyFirstElixirVibeCodeWeb, :live_view + + alias MyFirstElixirVibeCode.Reviews + + @impl true + def render(assigns) do + ~H""" + + <.header> + Review {@review.id} + <:subtitle>This is a review record from your database. + <:actions> + <.button navigate={~p"/reviews"}> + <.icon name="hero-arrow-left" /> + + <.button variant="primary" navigate={~p"/reviews/#{@review}/edit?return_to=show"}> + <.icon name="hero-pencil-square" /> Edit review + + + + + <.list> + <:item title="Rating">{@review.rating} + <:item title="Title">{@review.title} + <:item title="Content">{@review.content} + <:item title="Project type">{@review.project_type} + + + """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Show Review") + |> assign(:review, Reviews.get_review!(id))} + end +end diff --git a/lib/my_first_elixir_vibe_code_web/live/user_login_live.ex b/lib/my_first_elixir_vibe_code_web/live/user_login_live.ex new file mode 100644 index 0000000..0b56341 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/live/user_login_live.ex @@ -0,0 +1,62 @@ +defmodule MyFirstElixirVibeCodeWeb.UserLoginLive do + use MyFirstElixirVibeCodeWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + +
+
+
+

Login

+
+ + +
+ + +
+
+ + +
+
+ +
+
+ +
OR
+ +
+

+ Don't have an account? + Register +

+
+
+
+
+
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end +end diff --git a/lib/my_first_elixir_vibe_code_web/live/user_registration_live.ex b/lib/my_first_elixir_vibe_code_web/live/user_registration_live.ex new file mode 100644 index 0000000..aa3cb84 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/live/user_registration_live.ex @@ -0,0 +1,105 @@ +defmodule MyFirstElixirVibeCodeWeb.UserRegistrationLive do + use MyFirstElixirVibeCodeWeb, :live_view + + alias MyFirstElixirVibeCode.Accounts + alias MyFirstElixirVibeCode.Accounts.User + + @impl true + def render(assigns) do + ~H""" + +
+
+
+

Register

+ <.form + for={@form} + id="registration_form" + phx-submit="save" + phx-change="validate" + class="space-y-4" + > +
+ + <.input field={@form[:email]} type="email" required /> +
+ +
+ + <.input field={@form[:password]} type="password" required /> + +
+ +
+ +
+ + +
OR
+ +
+

+ Already have an account? + Log in +

+
+
+
+
+
+ """ + end + + @impl true + def mount(_params, _session, socket) do + changeset = Accounts.change_user(%User{}) + + socket = + socket + |> assign(trigger_submit: false) + |> assign_form(changeset) + + {:ok, socket, temporary_assigns: [form: nil]} + end + + @impl true + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = + %User{} + |> Accounts.change_user(user_params) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.create_user(user_params) do + {:ok, user} -> + {:noreply, + socket + |> put_flash(:info, "User created successfully") + |> push_navigate(to: ~p"/login")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))} + end + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + form = to_form(changeset, as: "user") + + if changeset.valid? do + assign(socket, form: form, check_errors: false) + else + assign(socket, form: form) + end + end +end diff --git a/lib/my_first_elixir_vibe_code_web/router.ex b/lib/my_first_elixir_vibe_code_web/router.ex new file mode 100644 index 0000000..d9621bd --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/router.ex @@ -0,0 +1,98 @@ +defmodule MyFirstElixirVibeCodeWeb.Router do + use MyFirstElixirVibeCodeWeb, :router + + alias MyFirstElixirVibeCodeWeb.UserAuth + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {MyFirstElixirVibeCodeWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + plug UserAuth, :fetch_current_user + end + + pipeline :api do + plug :accepts, ["json"] + end + + pipeline :guest_only do + plug UserAuth, :redirect_if_user_is_authenticated + end + + pipeline :auth_required do + plug UserAuth, :require_authenticated_user + end + + pipeline :admin_only do + plug UserAuth, :require_admin_user + end + + scope "/", MyFirstElixirVibeCodeWeb do + pipe_through :browser + + get "/", PageController, :home + + live "/contractor/register", ContractorRegistrationLive + live "/contact", ContactLive + + live "/reviews", ReviewLive.Index, :index + live "/reviews/new", ReviewLive.Form, :new + live "/reviews/:id", ReviewLive.Show, :show + live "/reviews/:id/edit", ReviewLive.Form, :edit + end + + ## Authentication routes + scope "/", MyFirstElixirVibeCodeWeb do + pipe_through [:browser, :guest_only] + + live_session :redirect_if_user_is_authenticated, + on_mount: [{MyFirstElixirVibeCodeWeb.UserAuth, :redirect_if_user_is_authenticated}] do + live "/register", UserRegistrationLive, :new + live "/login", UserLoginLive, :new + live "/admin/login", AdminLoginLive, :new + end + + post "/login", UserSessionController, :create + post "/admin/login", UserSessionController, :create_admin + end + + scope "/", MyFirstElixirVibeCodeWeb do + pipe_through [:browser, :auth_required] + + delete "/logout", UserSessionController, :delete + end + + ## Admin routes + scope "/admin", MyFirstElixirVibeCodeWeb do + pipe_through [:browser, :auth_required, :admin_only] + + live_session :require_admin_user, + on_mount: [{MyFirstElixirVibeCodeWeb.UserAuth, :ensure_authenticated}] do + live "/dashboard", AdminDashboardLive + end + end + + # Other scopes may use custom stacks. + # scope "/api", MyFirstElixirVibeCodeWeb do + # pipe_through :api + # end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:my_first_elixir_vibe_code, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through :browser + + live_dashboard "/dashboard", metrics: MyFirstElixirVibeCodeWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end diff --git a/lib/my_first_elixir_vibe_code_web/telemetry.ex b/lib/my_first_elixir_vibe_code_web/telemetry.ex new file mode 100644 index 0000000..21d052e --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule MyFirstElixirVibeCodeWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("my_first_elixir_vibe_code.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("my_first_elixir_vibe_code.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("my_first_elixir_vibe_code.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("my_first_elixir_vibe_code.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("my_first_elixir_vibe_code.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {MyFirstElixirVibeCodeWeb, :count_users, []} + ] + end +end diff --git a/lib/my_first_elixir_vibe_code_web/user_auth.ex b/lib/my_first_elixir_vibe_code_web/user_auth.ex new file mode 100644 index 0000000..1f7a772 --- /dev/null +++ b/lib/my_first_elixir_vibe_code_web/user_auth.ex @@ -0,0 +1,173 @@ +defmodule MyFirstElixirVibeCodeWeb.UserAuth do + @moduledoc """ + User authentication module for managing user sessions. + """ + + use MyFirstElixirVibeCodeWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias MyFirstElixirVibeCode.Accounts + + @max_age 60 * 60 * 24 * 60 # 60 days + @remember_me_cookie "_my_first_elixir_vibe_code_web_user_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + # Plug behaviour + def init(opts), do: opts + + def call(conn, opts) when is_atom(opts) do + apply(__MODULE__, opts, [conn, opts]) + end + + @doc """ + Logs the user in. + + It renews the session ID to avoid fixation attacks. + """ + def log_in_user(conn, user) do + token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + user_return_to = get_session(conn, :user_return_to) + + conn + |> renew_session() + |> put_token_in_session(token) + |> put_session(:user_id, user.id) + |> put_session(:is_admin, user.is_admin) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + |> redirect(to: user_return_to || signed_in_path(user)) + end + + defp signed_in_path(user) do + if user.is_admin do + ~p"/admin/dashboard" + else + ~p"/" + end + end + + defp renew_session(conn) do + conn + |> configure_session(renew: true) + |> clear_session() + end + + @doc """ + Logs the user out. + + It clears all session data for safety. + """ + def log_out_user(conn) do + if live_socket_id = get_session(conn, :live_socket_id) do + MyFirstElixirVibeCodeWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: ~p"/") + end + + @doc """ + Authenticates the user by looking into the session. + """ + def fetch_current_user(conn, _opts) do + user_id = get_session(conn, :user_id) + user = user_id && Accounts.get_user!(user_id) + assign(conn, :current_user, user) + end + + @doc """ + Handles mounting and authenticating the current_user in LiveViews. + """ + def on_mount(:mount_current_user, _params, session, socket) do + {:cont, mount_current_user(socket, session)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/login") + + {:halt, socket} + end + end + + def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket.assigns.current_user))} + else + {:cont, socket} + end + end + + defp mount_current_user(socket, session) do + Phoenix.Component.assign_new(socket, :current_user, fn -> + if user_id = session["user_id"] do + Accounts.get_user!(user_id) + end + end) + end + + @doc """ + Used for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns[:current_user] do + conn + |> redirect(to: signed_in_path(conn.assigns.current_user)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the user to be authenticated. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns[:current_user] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/login") + |> halt() + end + end + + @doc """ + Used for routes that require the user to be an admin. + """ + def require_admin_user(conn, _opts) do + if conn.assigns[:current_user] && conn.assigns.current_user.is_admin do + conn + else + conn + |> put_flash(:error, "You must be an admin to access this page.") + |> redirect(to: ~p"/") + |> halt() + end + end + + defp put_token_in_session(conn, token) do + conn + |> put_session(:user_token, token) + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..567c1fd --- /dev/null +++ b/mix.exs @@ -0,0 +1,95 @@ +defmodule MyFirstElixirVibeCode.MixProject do + use Mix.Project + + def project do + [ + app: :my_first_elixir_vibe_code, + version: "0.1.0", + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + compilers: [:phoenix_live_view] ++ Mix.compilers(), + listeners: [Phoenix.CodeReloader] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {MyFirstElixirVibeCode.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + def cli do + [ + preferred_envs: [precommit: :test] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.8.1"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.13"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.1.0"}, + {:lazy_html, ">= 0.1.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.2.0", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:swoosh, "~> 1.16"}, + {:req, "~> 0.5"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.26"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.2.0"}, + {:bandit, "~> 1.5"}, + {:bcrypt_elixir, "~> 3.0"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["compile", "tailwind my_first_elixir_vibe_code", "esbuild my_first_elixir_vibe_code"], + "assets.deploy": [ + "tailwind my_first_elixir_vibe_code --minify", + "esbuild my_first_elixir_vibe_code --minify", + "phx.digest" + ], + precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..c1c9a5d --- /dev/null +++ b/mix.lock @@ -0,0 +1,48 @@ +%{ + "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, + "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, + "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "phoenix": {:hex, :phoenix, "1.8.2", "75aba5b90081d88a54f2fc6a26453d4e76762ab095ff89be5a3e7cb28bff9300", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "19ea65b4064f17b1ab0515595e4d0ea65742ab068259608d5d7b139a73f47611"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.18", "b5410017b3d4edf261d9c98ebc334e0637d7189457c730720cfc13e206443d43", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f189b759595feff0420e9a1d544396397f9cf9e2d5a8cb98ba5b6cab01927da0"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, + "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, + "swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"}, + "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, +} diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..844c4f5 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot new file mode 100644 index 0000000..eef2de2 --- /dev/null +++ b/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/migrations/20251128193059_create_clients.exs b/priv/repo/migrations/20251128193059_create_clients.exs new file mode 100644 index 0000000..c6d6d60 --- /dev/null +++ b/priv/repo/migrations/20251128193059_create_clients.exs @@ -0,0 +1,13 @@ +defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateClients do + use Ecto.Migration + + def change do + create table(:clients) do + add :name, :string + add :email, :string + add :company_name, :string + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20251128193112_create_contractors.exs b/priv/repo/migrations/20251128193112_create_contractors.exs new file mode 100644 index 0000000..9d3d894 --- /dev/null +++ b/priv/repo/migrations/20251128193112_create_contractors.exs @@ -0,0 +1,13 @@ +defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateContractors do + use Ecto.Migration + + def change do + create table(:contractors) do + add :name, :string + add :email, :string + add :company_name, :string + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20251128193117_create_reviews.exs b/priv/repo/migrations/20251128193117_create_reviews.exs new file mode 100644 index 0000000..efdc305 --- /dev/null +++ b/priv/repo/migrations/20251128193117_create_reviews.exs @@ -0,0 +1,19 @@ +defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateReviews do + use Ecto.Migration + + def change do + create table(:reviews) do + add :rating, :integer + add :title, :string + add :content, :text + add :project_type, :string + add :contractor_id, references(:contractors, on_delete: :nothing) + add :client_id, references(:clients, on_delete: :nothing) + + timestamps(type: :utc_datetime) + end + + create index(:reviews, [:contractor_id]) + create index(:reviews, [:client_id]) + end +end diff --git a/priv/repo/migrations/20251128193636_add_documents_to_contractors.exs b/priv/repo/migrations/20251128193636_add_documents_to_contractors.exs new file mode 100644 index 0000000..78bacb8 --- /dev/null +++ b/priv/repo/migrations/20251128193636_add_documents_to_contractors.exs @@ -0,0 +1,12 @@ +defmodule MyFirstElixirVibeCode.Repo.Migrations.AddDocumentsToContractors do + use Ecto.Migration + + def change do + alter table(:contractors) do + add :municipal_registration_proof, :string + add :insurance_proof, :string + add :registration_status, :string, default: "pending" + add :verified_at, :utc_datetime + end + end +end diff --git a/priv/repo/migrations/20251128193736_add_unique_index_to_contractor_email.exs b/priv/repo/migrations/20251128193736_add_unique_index_to_contractor_email.exs new file mode 100644 index 0000000..53654bf --- /dev/null +++ b/priv/repo/migrations/20251128193736_add_unique_index_to_contractor_email.exs @@ -0,0 +1,7 @@ +defmodule MyFirstElixirVibeCode.Repo.Migrations.AddUniqueIndexToContractorEmail do + use Ecto.Migration + + def change do + create unique_index(:contractors, [:email]) + end +end diff --git a/priv/repo/migrations/20251128194516_add_documents_to_reviews.exs b/priv/repo/migrations/20251128194516_add_documents_to_reviews.exs new file mode 100644 index 0000000..ff8c3b7 --- /dev/null +++ b/priv/repo/migrations/20251128194516_add_documents_to_reviews.exs @@ -0,0 +1,10 @@ +defmodule MyFirstElixirVibeCode.Repo.Migrations.AddDocumentsToReviews do + use Ecto.Migration + + def change do + alter table(:reviews) do + add :municipal_registration_proof, :string + add :work_permit_proof, :string + end + end +end diff --git a/priv/repo/migrations/20251128194912_add_client_info_to_reviews.exs b/priv/repo/migrations/20251128194912_add_client_info_to_reviews.exs new file mode 100644 index 0000000..6cb8503 --- /dev/null +++ b/priv/repo/migrations/20251128194912_add_client_info_to_reviews.exs @@ -0,0 +1,13 @@ +defmodule MyFirstElixirVibeCode.Repo.Migrations.AddClientInfoToReviews do + use Ecto.Migration + + def change do + alter table(:reviews) do + add :client_name, :string + add :client_address, :string + end + + create index(:reviews, [:client_name]) + create index(:reviews, [:client_address]) + end +end diff --git a/priv/repo/migrations/20251128201445_update_client_fields_in_reviews.exs b/priv/repo/migrations/20251128201445_update_client_fields_in_reviews.exs new file mode 100644 index 0000000..ff4bc53 --- /dev/null +++ b/priv/repo/migrations/20251128201445_update_client_fields_in_reviews.exs @@ -0,0 +1,28 @@ +defmodule MyFirstElixirVibeCode.Repo.Migrations.UpdateClientFieldsInReviews do + use Ecto.Migration + + def change do + alter table(:reviews) do + # Remove old fields + remove :client_name + remove :client_address + + # Add split name fields + add :client_first_name, :string + add :client_last_name, :string + + # Add address components + add :client_street_address, :string + add :client_city, :string + add :client_state, :string + add :client_zip, :string + add :client_country, :string + end + + # Create indexes for searching + create index(:reviews, [:client_first_name]) + create index(:reviews, [:client_last_name]) + create index(:reviews, [:client_street_address]) + create index(:reviews, [:client_city]) + end +end diff --git a/priv/repo/migrations/20251128213538_create_users.exs b/priv/repo/migrations/20251128213538_create_users.exs new file mode 100644 index 0000000..e855577 --- /dev/null +++ b/priv/repo/migrations/20251128213538_create_users.exs @@ -0,0 +1,15 @@ +defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateUsers do + use Ecto.Migration + + def change do + create table(:users) do + add :email, :string, null: false + add :password_hash, :string, null: false + add :is_admin, :boolean, default: false, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:email]) + end +end diff --git a/priv/repo/migrations/20251128214700_add_username_to_users.exs b/priv/repo/migrations/20251128214700_add_username_to_users.exs new file mode 100644 index 0000000..de25c17 --- /dev/null +++ b/priv/repo/migrations/20251128214700_add_username_to_users.exs @@ -0,0 +1,11 @@ +defmodule MyFirstElixirVibeCode.Repo.Migrations.AddUsernameToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :username, :string + end + + create unique_index(:users, [:username]) + end +end diff --git a/priv/repo/migrations/20251128214959_make_email_nullable.exs b/priv/repo/migrations/20251128214959_make_email_nullable.exs new file mode 100644 index 0000000..3d3b1a3 --- /dev/null +++ b/priv/repo/migrations/20251128214959_make_email_nullable.exs @@ -0,0 +1,9 @@ +defmodule MyFirstElixirVibeCode.Repo.Migrations.MakeEmailNullable do + use Ecto.Migration + + def change do + alter table(:users) do + modify :email, :string, null: true + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..ac1c06e --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# MyFirstElixirVibeCode.Repo.insert!(%MyFirstElixirVibeCode.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000..7f372bf Binary files /dev/null and b/priv/static/favicon.ico differ diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/priv/static/robots.txt b/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/test/my_first_elixir_vibe_code/accounts_test.exs b/test/my_first_elixir_vibe_code/accounts_test.exs new file mode 100644 index 0000000..927d287 --- /dev/null +++ b/test/my_first_elixir_vibe_code/accounts_test.exs @@ -0,0 +1,121 @@ +defmodule MyFirstElixirVibeCode.AccountsTest do + use MyFirstElixirVibeCode.DataCase + + alias MyFirstElixirVibeCode.Accounts + + describe "clients" do + alias MyFirstElixirVibeCode.Accounts.Client + + import MyFirstElixirVibeCode.AccountsFixtures + + @invalid_attrs %{name: nil, email: nil, company_name: nil} + + test "list_clients/0 returns all clients" do + client = client_fixture() + assert Accounts.list_clients() == [client] + end + + test "get_client!/1 returns the client with given id" do + client = client_fixture() + assert Accounts.get_client!(client.id) == client + end + + test "create_client/1 with valid data creates a client" do + valid_attrs = %{name: "some name", email: "some email", company_name: "some company_name"} + + assert {:ok, %Client{} = client} = Accounts.create_client(valid_attrs) + assert client.name == "some name" + assert client.email == "some email" + assert client.company_name == "some company_name" + end + + test "create_client/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Accounts.create_client(@invalid_attrs) + end + + test "update_client/2 with valid data updates the client" do + client = client_fixture() + update_attrs = %{name: "some updated name", email: "some updated email", company_name: "some updated company_name"} + + assert {:ok, %Client{} = client} = Accounts.update_client(client, update_attrs) + assert client.name == "some updated name" + assert client.email == "some updated email" + assert client.company_name == "some updated company_name" + end + + test "update_client/2 with invalid data returns error changeset" do + client = client_fixture() + assert {:error, %Ecto.Changeset{}} = Accounts.update_client(client, @invalid_attrs) + assert client == Accounts.get_client!(client.id) + end + + test "delete_client/1 deletes the client" do + client = client_fixture() + assert {:ok, %Client{}} = Accounts.delete_client(client) + assert_raise Ecto.NoResultsError, fn -> Accounts.get_client!(client.id) end + end + + test "change_client/1 returns a client changeset" do + client = client_fixture() + assert %Ecto.Changeset{} = Accounts.change_client(client) + end + end + + describe "contractors" do + alias MyFirstElixirVibeCode.Accounts.Contractor + + import MyFirstElixirVibeCode.AccountsFixtures + + @invalid_attrs %{name: nil, email: nil, company_name: nil} + + test "list_contractors/0 returns all contractors" do + contractor = contractor_fixture() + assert Accounts.list_contractors() == [contractor] + end + + test "get_contractor!/1 returns the contractor with given id" do + contractor = contractor_fixture() + assert Accounts.get_contractor!(contractor.id) == contractor + end + + test "create_contractor/1 with valid data creates a contractor" do + valid_attrs = %{name: "some name", email: "some email", company_name: "some company_name"} + + assert {:ok, %Contractor{} = contractor} = Accounts.create_contractor(valid_attrs) + assert contractor.name == "some name" + assert contractor.email == "some email" + assert contractor.company_name == "some company_name" + end + + test "create_contractor/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Accounts.create_contractor(@invalid_attrs) + end + + test "update_contractor/2 with valid data updates the contractor" do + contractor = contractor_fixture() + update_attrs = %{name: "some updated name", email: "some updated email", company_name: "some updated company_name"} + + assert {:ok, %Contractor{} = contractor} = Accounts.update_contractor(contractor, update_attrs) + assert contractor.name == "some updated name" + assert contractor.email == "some updated email" + assert contractor.company_name == "some updated company_name" + end + + test "update_contractor/2 with invalid data returns error changeset" do + contractor = contractor_fixture() + assert {:error, %Ecto.Changeset{}} = Accounts.update_contractor(contractor, @invalid_attrs) + assert contractor == Accounts.get_contractor!(contractor.id) + end + + test "delete_contractor/1 deletes the contractor" do + contractor = contractor_fixture() + assert {:ok, %Contractor{}} = Accounts.delete_contractor(contractor) + assert_raise Ecto.NoResultsError, fn -> Accounts.get_contractor!(contractor.id) end + end + + test "change_contractor/1 returns a contractor changeset" do + contractor = contractor_fixture() + assert %Ecto.Changeset{} = Accounts.change_contractor(contractor) + end + end +end diff --git a/test/my_first_elixir_vibe_code/reviews_test.exs b/test/my_first_elixir_vibe_code/reviews_test.exs new file mode 100644 index 0000000..144baff --- /dev/null +++ b/test/my_first_elixir_vibe_code/reviews_test.exs @@ -0,0 +1,65 @@ +defmodule MyFirstElixirVibeCode.ReviewsTest do + use MyFirstElixirVibeCode.DataCase + + alias MyFirstElixirVibeCode.Reviews + + describe "reviews" do + alias MyFirstElixirVibeCode.Reviews.Review + + import MyFirstElixirVibeCode.ReviewsFixtures + + @invalid_attrs %{title: nil, rating: nil, content: nil, project_type: nil} + + test "list_reviews/0 returns all reviews" do + review = review_fixture() + assert Reviews.list_reviews() == [review] + end + + test "get_review!/1 returns the review with given id" do + review = review_fixture() + assert Reviews.get_review!(review.id) == review + end + + test "create_review/1 with valid data creates a review" do + valid_attrs = %{title: "some title", rating: 42, content: "some content", project_type: "some project_type"} + + assert {:ok, %Review{} = review} = Reviews.create_review(valid_attrs) + assert review.title == "some title" + assert review.rating == 42 + assert review.content == "some content" + assert review.project_type == "some project_type" + end + + test "create_review/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Reviews.create_review(@invalid_attrs) + end + + test "update_review/2 with valid data updates the review" do + review = review_fixture() + update_attrs = %{title: "some updated title", rating: 43, content: "some updated content", project_type: "some updated project_type"} + + assert {:ok, %Review{} = review} = Reviews.update_review(review, update_attrs) + assert review.title == "some updated title" + assert review.rating == 43 + assert review.content == "some updated content" + assert review.project_type == "some updated project_type" + end + + test "update_review/2 with invalid data returns error changeset" do + review = review_fixture() + assert {:error, %Ecto.Changeset{}} = Reviews.update_review(review, @invalid_attrs) + assert review == Reviews.get_review!(review.id) + end + + test "delete_review/1 deletes the review" do + review = review_fixture() + assert {:ok, %Review{}} = Reviews.delete_review(review) + assert_raise Ecto.NoResultsError, fn -> Reviews.get_review!(review.id) end + end + + test "change_review/1 returns a review changeset" do + review = review_fixture() + assert %Ecto.Changeset{} = Reviews.change_review(review) + end + end +end diff --git a/test/my_first_elixir_vibe_code_web/controllers/error_html_test.exs b/test/my_first_elixir_vibe_code_web/controllers/error_html_test.exs new file mode 100644 index 0000000..dbb1f38 --- /dev/null +++ b/test/my_first_elixir_vibe_code_web/controllers/error_html_test.exs @@ -0,0 +1,14 @@ +defmodule MyFirstElixirVibeCodeWeb.ErrorHTMLTest do + use MyFirstElixirVibeCodeWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template, only: [render_to_string: 4] + + test "renders 404.html" do + assert render_to_string(MyFirstElixirVibeCodeWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(MyFirstElixirVibeCodeWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/test/my_first_elixir_vibe_code_web/controllers/error_json_test.exs b/test/my_first_elixir_vibe_code_web/controllers/error_json_test.exs new file mode 100644 index 0000000..17d47ba --- /dev/null +++ b/test/my_first_elixir_vibe_code_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule MyFirstElixirVibeCodeWeb.ErrorJSONTest do + use MyFirstElixirVibeCodeWeb.ConnCase, async: true + + test "renders 404" do + assert MyFirstElixirVibeCodeWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert MyFirstElixirVibeCodeWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/test/my_first_elixir_vibe_code_web/controllers/page_controller_test.exs b/test/my_first_elixir_vibe_code_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..f6e2667 --- /dev/null +++ b/test/my_first_elixir_vibe_code_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule MyFirstElixirVibeCodeWeb.PageControllerTest do + use MyFirstElixirVibeCodeWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + end +end diff --git a/test/my_first_elixir_vibe_code_web/live/review_live_test.exs b/test/my_first_elixir_vibe_code_web/live/review_live_test.exs new file mode 100644 index 0000000..2221856 --- /dev/null +++ b/test/my_first_elixir_vibe_code_web/live/review_live_test.exs @@ -0,0 +1,122 @@ +defmodule MyFirstElixirVibeCodeWeb.ReviewLiveTest do + use MyFirstElixirVibeCodeWeb.ConnCase + + import Phoenix.LiveViewTest + import MyFirstElixirVibeCode.ReviewsFixtures + + @create_attrs %{title: "some title", rating: 42, content: "some content", project_type: "some project_type"} + @update_attrs %{title: "some updated title", rating: 43, content: "some updated content", project_type: "some updated project_type"} + @invalid_attrs %{title: nil, rating: nil, content: nil, project_type: nil} + defp create_review(_) do + review = review_fixture() + + %{review: review} + end + + describe "Index" do + setup [:create_review] + + test "lists all reviews", %{conn: conn, review: review} do + {:ok, _index_live, html} = live(conn, ~p"/reviews") + + assert html =~ "Listing Reviews" + assert html =~ review.title + end + + test "saves new review", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/reviews") + + assert {:ok, form_live, _} = + index_live + |> element("a", "New Review") + |> render_click() + |> follow_redirect(conn, ~p"/reviews/new") + + assert render(form_live) =~ "New Review" + + assert form_live + |> form("#review-form", review: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert {:ok, index_live, _html} = + form_live + |> form("#review-form", review: @create_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/reviews") + + html = render(index_live) + assert html =~ "Review created successfully" + assert html =~ "some title" + end + + test "updates review in listing", %{conn: conn, review: review} do + {:ok, index_live, _html} = live(conn, ~p"/reviews") + + assert {:ok, form_live, _html} = + index_live + |> element("#reviews-#{review.id} a", "Edit") + |> render_click() + |> follow_redirect(conn, ~p"/reviews/#{review}/edit") + + assert render(form_live) =~ "Edit Review" + + assert form_live + |> form("#review-form", review: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert {:ok, index_live, _html} = + form_live + |> form("#review-form", review: @update_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/reviews") + + html = render(index_live) + assert html =~ "Review updated successfully" + assert html =~ "some updated title" + end + + test "deletes review in listing", %{conn: conn, review: review} do + {:ok, index_live, _html} = live(conn, ~p"/reviews") + + assert index_live |> element("#reviews-#{review.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#reviews-#{review.id}") + end + end + + describe "Show" do + setup [:create_review] + + test "displays review", %{conn: conn, review: review} do + {:ok, _show_live, html} = live(conn, ~p"/reviews/#{review}") + + assert html =~ "Show Review" + assert html =~ review.title + end + + test "updates review and returns to show", %{conn: conn, review: review} do + {:ok, show_live, _html} = live(conn, ~p"/reviews/#{review}") + + assert {:ok, form_live, _} = + show_live + |> element("a", "Edit") + |> render_click() + |> follow_redirect(conn, ~p"/reviews/#{review}/edit?return_to=show") + + assert render(form_live) =~ "Edit Review" + + assert form_live + |> form("#review-form", review: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert {:ok, show_live, _html} = + form_live + |> form("#review-form", review: @update_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/reviews/#{review}") + + html = render(show_live) + assert html =~ "Review updated successfully" + assert html =~ "some updated title" + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..9c24b5e --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule MyFirstElixirVibeCodeWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use MyFirstElixirVibeCodeWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint MyFirstElixirVibeCodeWeb.Endpoint + + use MyFirstElixirVibeCodeWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import MyFirstElixirVibeCodeWeb.ConnCase + end + end + + setup tags do + MyFirstElixirVibeCode.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..2c77501 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule MyFirstElixirVibeCode.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use MyFirstElixirVibeCode.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias MyFirstElixirVibeCode.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import MyFirstElixirVibeCode.DataCase + end + end + + setup tags do + MyFirstElixirVibeCode.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyFirstElixirVibeCode.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..3e4e395 --- /dev/null +++ b/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,38 @@ +defmodule MyFirstElixirVibeCode.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `MyFirstElixirVibeCode.Accounts` context. + """ + + @doc """ + Generate a client. + """ + def client_fixture(attrs \\ %{}) do + {:ok, client} = + attrs + |> Enum.into(%{ + company_name: "some company_name", + email: "some email", + name: "some name" + }) + |> MyFirstElixirVibeCode.Accounts.create_client() + + client + end + + @doc """ + Generate a contractor. + """ + def contractor_fixture(attrs \\ %{}) do + {:ok, contractor} = + attrs + |> Enum.into(%{ + company_name: "some company_name", + email: "some email", + name: "some name" + }) + |> MyFirstElixirVibeCode.Accounts.create_contractor() + + contractor + end +end diff --git a/test/support/fixtures/reviews_fixtures.ex b/test/support/fixtures/reviews_fixtures.ex new file mode 100644 index 0000000..6ff65db --- /dev/null +++ b/test/support/fixtures/reviews_fixtures.ex @@ -0,0 +1,23 @@ +defmodule MyFirstElixirVibeCode.ReviewsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `MyFirstElixirVibeCode.Reviews` context. + """ + + @doc """ + Generate a review. + """ + def review_fixture(attrs \\ %{}) do + {:ok, review} = + attrs + |> Enum.into(%{ + content: "some content", + project_type: "some project_type", + rating: 42, + title: "some title" + }) + |> MyFirstElixirVibeCode.Reviews.create_review() + + review + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..09a5d8e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(MyFirstElixirVibeCode.Repo, :manual)