diff --git a/.credo.exs b/.credo.exs index 4eddee8..d871572 100644 --- a/.credo.exs +++ b/.credo.exs @@ -158,11 +158,11 @@ {Credo.Check.Warning.UnusedRegexOperation, []}, {Credo.Check.Warning.UnusedStringOperation, []}, {Credo.Check.Warning.UnusedTupleOperation, []}, - {Credo.Check.Warning.WrongTestFileExtension, []}, - # Module documentation check (enabled after adding @moduledoc to all modules) - {Credo.Check.Readability.ModuleDoc, []} + {Credo.Check.Warning.WrongTestFileExtension, []} ], disabled: [ + # Checks disabled by the Mitgliederverwaltung Team + {Credo.Check.Readability.ModuleDoc, []}, # # Checks scheduled for next check update (opt-in for now) {Credo.Check.Refactor.UtcNowTruncate, []}, diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md deleted file mode 100644 index 4a82edb..0000000 --- a/CODE_GUIDELINES.md +++ /dev/null @@ -1,2578 +0,0 @@ -# Code Guidelines and Best Practices - -## Purpose - -This document serves as a foundational reference for our development team, ensuring consistency, maintainability, and quality in our codebase. It defines standards and best practices for building the Mila membership management application. - -## Project Context - -We are building a membership management system (Mila) using the following technology stack: - -**Backend & Runtime:** -- Elixir `~> 1.15` (currently 1.18.3-otp-27) -- Erlang/OTP 27.3.4 -- Phoenix Framework `~> 1.8.0` -- Ash Framework `~> 3.0` -- AshPostgres `~> 2.0` -- Ecto `~> 3.10` -- Postgrex `>= 0.0.0` -- AshAuthentication `~> 4.9` -- AshAuthenticationPhoenix `~> 2.10` -- bcrypt_elixir `~> 3.0` - -**Frontend & UI:** -- Phoenix LiveView `~> 1.1.0` -- Phoenix HTML `~> 4.1` -- Tailwind CSS 4.0.9 -- DaisyUI (as Tailwind plugin) -- Heroicons v2.2.0 -- JavaScript (ES2022) -- esbuild `~> 0.9` - -**Database:** -- PostgreSQL 17.6 (dev), 16 (prod) - -**Testing:** -- ExUnit (built-in) -- Ecto.Adapters.SQL.Sandbox - -**Development Tools:** -- asdf 0.16.5 (version management) -- Just 1.43.0 (task runner) -- Credo `~> 1.7` (code analysis) -- Sobelow `~> 0.14` (security analysis) -- mix_audit `~> 2.1` (dependency audit) - -**Infrastructure:** -- Docker & Docker Compose -- Bandit `~> 1.5` (HTTP server) - ---- - -## Table of Contents - -1. [Setup and Architectural Conventions](#1-setup-and-architectural-conventions) -2. [Coding Standards and Style](#2-coding-standards-and-style) -3. [Tooling Guidelines](#3-tooling-guidelines) -4. [Testing Standards](#4-testing-standards) -5. [Security Guidelines](#5-security-guidelines) -6. [Performance Best Practices](#6-performance-best-practices) -7. [Documentation Standards](#7-documentation-standards) -8. [Accessibility Guidelines](#8-accessibility-guidelines) - ---- - -## 1. Setup and Architectural Conventions - -### 1.1 Project Structure - -Our project follows a domain-driven design approach using Phoenix contexts and Ash domains: - -``` -lib/ -├── accounts/ # Accounts domain (AshAuthentication) -│ ├── accounts.ex # Domain definition -│ ├── user.ex # User resource -│ ├── token.ex # Token resource -│ ├── user_identity.exs # User identity helpers -│ └── user/ # User-related modules -│ ├── changes/ # Ash changes for user -│ └── preparations/ # Ash preparations for user -├── membership/ # Membership domain -│ ├── membership.ex # Domain definition -│ ├── member.ex # Member resource -│ ├── property.ex # Custom property resource -│ ├── property_type.ex # Property type resource -│ └── email.ex # Email custom type -├── mv/ # Core application modules -│ ├── accounts/ # Domain-specific logic -│ │ └── user/ -│ │ ├── senders/ # Email senders for user actions -│ │ └── validations/ -│ ├── email_sync/ # Email synchronization logic -│ │ ├── changes/ # Sync changes -│ │ ├── helpers.ex # Sync helper functions -│ │ └── loader.ex # Data loaders -│ ├── membership/ # Domain-specific logic -│ │ └── member/ -│ │ └── validations/ -│ ├── application.ex # OTP application -│ ├── mailer.ex # Email mailer -│ ├── release.ex # Release tasks -│ ├── repo.ex # Database repository -│ └── secrets.ex # Secret management -├── mv_web/ # Web interface layer -│ ├── components/ # UI components -│ │ ├── core_components.ex -│ │ ├── table_components.ex -│ │ ├── layouts.ex -│ │ └── layouts/ # Layout templates -│ │ ├── navbar.ex -│ │ └── root.html.heex -│ ├── controllers/ # HTTP controllers -│ │ ├── auth_controller.ex -│ │ ├── page_controller.ex -│ │ ├── locale_controller.ex -│ │ ├── error_html.ex -│ │ ├── error_json.ex -│ │ └── page_html/ -│ ├── live/ # LiveView modules -│ │ ├── components/ # LiveView-specific components -│ │ │ ├── search_bar_component.ex -│ │ │ └── sort_header_component.ex -│ │ ├── member_live/ # Member CRUD LiveViews -│ │ ├── property_live/ # Property CRUD LiveViews -│ │ ├── property_type_live/ -│ │ └── user_live/ # User management LiveViews -│ ├── auth_overrides.ex # AshAuthentication overrides -│ ├── endpoint.ex # Phoenix endpoint -│ ├── gettext.ex # I18n configuration -│ ├── live_helpers.ex # LiveView helpers -│ ├── live_user_auth.ex # LiveView authentication -│ ├── router.ex # Application router -│ └── telemetry.ex # Telemetry configuration -├── mv_web.ex # Web module definition -└── mv.ex # Application module definition - -test/ -├── accounts/ # Accounts domain tests -│ ├── user_test.exs -│ ├── email_sync_edge_cases_test.exs -│ ├── email_uniqueness_test.exs -│ ├── user_email_sync_test.exs -│ ├── user_member_deletion_test.exs -│ └── user_member_relationship_test.exs -├── membership/ # Membership domain tests -│ ├── member_test.exs -│ └── member_email_sync_test.exs -├── mv_web/ # Web layer tests -│ ├── components/ # Component tests -│ │ ├── layouts/ -│ │ │ └── navbar_test.exs -│ │ ├── search_bar_component_test.exs -│ │ └── sort_header_component_test.exs -│ ├── controllers/ # Controller tests -│ │ ├── auth_controller_test.exs -│ │ ├── error_html_test.exs -│ │ ├── error_json_test.exs -│ │ ├── oidc_integration_test.exs -│ │ └── page_controller_test.exs -│ ├── live/ # LiveView tests -│ │ └── profile_navigation_test.exs -│ ├── member_live/ # Member LiveView tests -│ │ └── index_test.exs -│ ├── user_live/ # User LiveView tests -│ │ ├── form_test.exs -│ │ └── index_test.exs -│ └── locale_test.exs -├── seeds_test.exs # Database seed tests -└── support/ # Test helpers - ├── conn_case.ex # Controller test helpers - └── data_case.ex # Data layer test helpers -``` - -### 1.2 Module Organization - -**Module Naming:** - -- **Modules:** Use `PascalCase` with full namespace (e.g., `Mv.Accounts.User`) -- **Domains:** Top-level domains are `Mv.Accounts` and `Mv.Membership` -- **Resources:** Resource modules should be singular nouns (e.g., `Member`, not `Members`) -- **Context functions:** Use `snake_case` and verb-first naming (e.g., `create_user`, `list_members`) - -**Module Structure:** - -```elixir -defmodule Mv.Membership.Member do - @moduledoc """ - Represents a club member with their personal information and membership status. - """ - - use Ash.Resource, - domain: Mv.Membership, - data_layer: AshPostgres.DataLayer - - # 1. Ash DSL sections in order (see Spark formatter config) - admin do - # ... - end - - postgres do - # ... - end - - resource do - # ... - end - - code_interface do - # ... - end - - actions do - # ... - end - - policies do - # ... - end - - attributes do - # ... - end - - relationships do - # ... - end - - # 2. Public functions - - # 3. Private functions -end -``` - -### 1.3 Domain-Driven Design - -**Use Ash Domains for Context Boundaries:** - -Each domain should: -- Have a clear boundary and responsibility -- Define a public API through code interfaces -- Encapsulate business logic within resources -- Handle cross-domain communication explicitly - -Example domain definition: - -```elixir -defmodule Mv.Membership do - use Ash.Domain, - extensions: [AshAdmin.Domain, AshPhoenix] - - admin do - show? true - end - - resources do - resource Mv.Membership.Member do - define :create_member, action: :create_member - define :list_members, action: :read - define :update_member, action: :update_member - define :destroy_member, action: :destroy - end - end -end -``` - -### 1.4 Dependency Management - -- **Use `mix.exs` for all dependencies:** Define versions explicitly -- **Keep dependencies up to date:** Use Renovate for automated updates -- **Version management:** Use `asdf` with `.tool-versions` for consistent environments - -### 1.5 Scalability Considerations - -- **Database indexing:** Add indexes for frequently queried fields -- **Pagination:** Use Ash's keyset pagination for large datasets (default configured) -- **Background jobs:** Plan for Oban or similar for async processing -- **Caching:** Consider caching strategies for expensive operations -- **Process design:** Use OTP principles (GenServers, Supervisors) for stateful components - ---- - -## 2. Coding Standards and Style - -### 2.1 Code Formatting - -**Use `mix format` for all Elixir code:** - -```bash -mix format -``` - -**Key formatting rules:** -- **Indentation:** 2 spaces (no tabs) -- **Line length:** Maximum 120 characters (configured in `.credo.exs`) -- **Trailing whitespace:** Not allowed -- **File endings:** Always include trailing newline - -**Naming Conventions Summary:** - -- **Elixir:** Use `snake_case` for functions/variables, `PascalCase` for modules -- **Phoenix:** Controllers end with `Controller`, LiveViews end with `Live` -- **Ash:** Resources are singular nouns, actions are verb-first (`:create_member`) -- **Files:** Match module names in `snake_case` (`user_controller.ex` for `UserController`) - -### 2.2 Function Design - -**Verb-First Function Names:** - -```elixir -# Good -def create_user(attrs) -def list_members(query) -def send_email(recipient, content) - -# Avoid -def user_create(attrs) -def members_list(query) -def email_send(recipient, content) -``` - -**Use Pattern Matching in Function Heads:** - -```elixir -# Good - multiple clauses with pattern matching -def handle_result({:ok, user}), do: {:ok, user} -def handle_result({:error, reason}), do: log_and_return_error(reason) - -# Avoid - case/cond when pattern matching suffices -def handle_result(result) do - case result do - {:ok, user} -> {:ok, user} - {:error, reason} -> log_and_return_error(reason) - end -end -``` - -**Keep Functions Small and Focused:** - -- Aim for functions under 20 lines -- Each function should have a single responsibility -- Extract complex logic into private helper functions - -**Use Guard Clauses for Early Returns:** - -```elixir -def process_user(nil), do: {:error, :user_not_found} -def process_user(%{active: false}), do: {:error, :user_inactive} -def process_user(user), do: {:ok, perform_action(user)} -``` - -### 2.3 Error Handling - -**Use Tagged Tuples:** - -```elixir -# Standard pattern -{:ok, result} | {:error, reason} - -# Examples -def create_member(attrs) do - case Ash.create(Member, attrs) do - {:ok, member} -> {:ok, member} - {:error, error} -> {:error, error} - end -end -``` - -**Use `with` for Complex Operations:** - -```elixir -def register_user(params) do - with {:ok, validated} <- validate_params(params), - {:ok, user} <- create_user(validated), - {:ok, _email} <- send_welcome_email(user) do - {:ok, user} - else - {:error, reason} -> {:error, reason} - end -end -``` - -**Let It Crash (with Supervision):** - -Don't defensively program against every possible error. Use supervisors to handle process failures: - -```elixir -# In your application.ex -children = [ - Mv.Repo, - MvWeb.Endpoint, - {Phoenix.PubSub, name: Mv.PubSub} -] - -Supervisor.start_link(children, strategy: :one_for_one) -``` - -### 2.4 Functional Programming Principles - -**Immutability:** - -```elixir -# Good - return new data structures -def add_role(user, role) do - %{user | roles: [role | user.roles]} -end - -# Avoid - mutation (not possible in Elixir anyway) -# This is just conceptual - Elixir prevents mutation -``` - -**Pure Functions:** - -Write functions that: -- Return the same output for the same input -- Have no side effects -- Are easier to test and reason about - -```elixir -# Pure function -def calculate_total(items) do - Enum.reduce(items, 0, fn item, acc -> acc + item.price end) -end - -# Impure function (side effects) -def create_and_log_user(attrs) do - Logger.info("Creating user: #{inspect(attrs)}") # Side effect - Ash.create!(User, attrs) # Side effect -end -``` - -**Pipe Operator:** - -Use the pipe operator `|>` for transformation chains: - -```elixir -# Good -def process_members(query) do - query - |> filter_active() - |> sort_by_name() - |> limit_results(10) -end - -# Avoid -def process_members(query) do - limit_results(sort_by_name(filter_active(query)), 10) -end -``` - -### 2.5 Elixir-Specific Patterns - -**Avoid Using Else with Unless:** - -```elixir -# Good -unless user.admin? do - {:error, :unauthorized} -end - -# Avoid - confusing -unless user.admin? do - {:error, :unauthorized} -else - perform_admin_action() -end -``` - -**Use `Enum` over List Comprehensions for Clarity:** - -```elixir -# Preferred for readability -users -|> Enum.filter(&(&1.active)) -|> Enum.map(&(&1.name)) - -# List comprehension (use when more concise) -for user <- users, user.active, do: user.name -``` - -**String Concatenation:** - -```elixir -# Good - interpolation -"Hello, #{user.name}!" - -# Avoid - concatenation with <> -"Hello, " <> user.name <> "!" -``` - ---- - -## 3. Tooling Guidelines - -### 3.1 Elixir & Erlang/OTP - -**Version Management with asdf:** - -Always use the versions specified in `.tool-versions`: - -```bash -# Install correct versions -asdf install - -# Verify versions -elixir --version # Should show 1.18.3 -erl -version # Should show 27.3.4 -``` - -**OTP Application Design:** - -```elixir -defmodule Mv.Application do - use Application - - def start(_type, _args) do - children = [ - # Start the database repository - Mv.Repo, - # Start the Telemetry supervisor - MvWeb.Telemetry, - # Start the PubSub system - {Phoenix.PubSub, name: Mv.PubSub}, - # Start the Endpoint - MvWeb.Endpoint - ] - - opts = [strategy: :one_for_one, name: Mv.Supervisor] - Supervisor.start_link(children, opts) - end -end -``` - -### 3.2 Phoenix Framework - -**Context-Based Organization:** - -- Use contexts to define API boundaries -- Keep controllers thin - delegate to contexts or Ash actions -- Avoid direct Repo/Ecto calls in controllers - -```elixir -# Good - thin controller -defmodule MvWeb.MemberController do - use MvWeb, :controller - - def create(conn, %{"member" => member_params}) do - case Mv.Membership.create_member(member_params) do - {:ok, member} -> - conn - |> put_flash(:info, "Member created successfully.") - |> redirect(to: ~p"/members/#{member}") - - {:error, error} -> - conn - |> put_flash(:error, "Failed to create member.") - |> render(:new, error: error) - end - end -end -``` - -**Phoenix LiveView Best Practices:** - -```elixir -defmodule MvWeb.MemberLive.Index do - use MvWeb, :live_view - - # Use mount for initial setup - def mount(_params, _session, socket) do - {:ok, assign(socket, members: [], loading: true)} - end - - # Use handle_params for URL parameter handling - def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} - end - - # Use handle_event for user interactions - def handle_event("delete", %{"id" => id}, socket) do - # Handle deletion - {:noreply, socket} - end - - # Use handle_info for asynchronous messages - def handle_info({:member_updated, member}, socket) do - {:noreply, update_member_in_list(socket, member)} - end -end -``` - -**Component Design:** - -```elixir -# Function components for stateless UI elements -def button(assigns) do - ~H""" - - """ -end - -# Use attrs and slots for documentation -attr :id, :string, required: true -attr :title, :string, default: nil -slot :inner_block, required: true - -def card(assigns) do - ~H""" -
-

<%= @title %>

- <%= render_slot(@inner_block) %> -
- """ -end -``` - -### 3.3 Ash Framework - -**Resource Definition Best Practices:** - -```elixir -defmodule Mv.Membership.Member do - use Ash.Resource, - domain: Mv.Membership, - data_layer: AshPostgres.DataLayer - - # Follow section order from Spark formatter config - postgres do - table "members" - repo Mv.Repo - end - - attributes do - uuid_primary_key :id - - attribute :first_name, :string do - allow_nil? false - public? true - end - - attribute :email, :string do - allow_nil? false - public? true - end - - timestamps() - end - - actions do - # Define specific actions instead of using defaults.accept_all - create :create_member do - accept [:first_name, :last_name, :email] - - change fn changeset, _context -> - # Custom validation or transformation - changeset - end - end - - read :read do - primary? true - end - - update :update_member do - accept [:first_name, :last_name, :email] - end - - destroy :destroy - end - - code_interface do - define :create_member - define :list_members, action: :read - define :update_member - define :destroy_member, action: :destroy - end - - identities do - identity :unique_email, [:email] - end -end -``` - -**Ash Policies:** - -```elixir -policies do - # Admin can do everything - policy action_type([:read, :create, :update, :destroy]) do - authorize_if actor_attribute_equals(:role, :admin) - end - - # Users can only read and update their own data - policy action_type([:read, :update]) do - authorize_if relates_to_actor_via(:user) - end -end -``` - -**Ash Validations:** - -```elixir -validations do - validate present(:email), on: [:create, :update] - validate match(:email, ~r/@/), message: "must be a valid email" - validate string_length(:first_name, min: 2, max: 100) -end -``` - -### 3.4 AshPostgres & Ecto - -**Migrations with Ash:** - -```bash -# Generate migration for all changes -mix ash.codegen --name add_members_table - -# Apply migrations -mix ash.setup -``` - -**Repository Configuration:** - -```elixir -defmodule Mv.Repo do - use AshPostgres.Repo, - otp_app: :mv - - # Install PostgreSQL extensions - def installed_extensions do - ["citext", "uuid-ossp"] - end -end -``` - -**Avoid N+1 Queries:** - -```elixir -# Good - preload relationships -members = - Member - |> Ash.Query.load(:properties) - |> Mv.Membership.list_members!() - -# Avoid - causes N+1 queries -members = Mv.Membership.list_members!() -Enum.map(members, fn member -> - # This triggers a query for each member - Ash.load!(member, :properties) -end) -``` - -### 3.5 Authentication (AshAuthentication) - -**Resource with Authentication:** - -```elixir -defmodule Mv.Accounts.User do - use Ash.Resource, - domain: Mv.Accounts, - data_layer: AshPostgres.DataLayer, - extensions: [AshAuthentication] - - authentication do - strategies do - password :password do - identity_field :email - hashed_password_field :hashed_password - end - - oauth2 :rauthy do - client_id fn _, _ -> - Application.fetch_env!(:mv, :rauthy)[:client_id] - end - # ... other config - end - end - end -end -``` - -### 3.6 Frontend: Tailwind CSS & DaisyUI - -**Utility-First Approach:** - -```heex - -
-
-

Member Name

-

Email: member@example.com

-
- -
-
-
- - -
-

Member Name

- -
-``` - -**Responsive Design:** - -```heex - -
- <%= for member <- @members do %> - <.member_card member={member} /> - <% end %> -
-``` - -**DaisyUI Components:** - -```heex - - -``` - -**Custom Tailwind Configuration:** - -Update `assets/tailwind.config.js` for custom needs: - -```javascript -module.exports = { - content: [ - "../lib/mv_web.ex", - "../lib/mv_web/**/*.*ex" - ], - theme: { - extend: { - colors: { - brand: "#FD4F00", - } - }, - }, - plugins: [ - require("@tailwindcss/forms"), - // DaisyUI loaded from vendor - ] -} -``` - -### 3.7 JavaScript & esbuild - -**Minimal JavaScript Philosophy:** - -Phoenix LiveView handles most interactivity. Use JavaScript only when necessary: - -```javascript -// assets/js/app.js -import "phoenix_html" -import {Socket} from "phoenix" -import {LiveSocket} from "phoenix_live_view" -import topbar from "../vendor/topbar" - -let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") -let liveSocket = new LiveSocket("/live", Socket, { - longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken} -}) - -// Show progress bar on live navigation -topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) -window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) -window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) - -liveSocket.connect() -``` - -**Custom Hooks (when needed):** - -```javascript -let Hooks = {} - -Hooks.DatePicker = { - mounted() { - // Initialize date picker - this.el.addEventListener("change", (e) => { - this.pushEvent("date_selected", {date: e.target.value}) - }) - } -} - -let liveSocket = new LiveSocket("/live", Socket, { - params: {_csrf_token: csrfToken}, - hooks: Hooks -}) -``` - -### 3.8 Code Quality: Credo - -**Run Credo Regularly:** - -```bash -# Check code quality -mix credo - -# Strict mode for CI -mix credo --strict -``` - -**Key Credo Checks Enabled:** - -- Consistency checks (spacing, line endings, parameter patterns) -- Design checks (FIXME/TODO tags, alias usage) -- Readability checks (max line length: 120, module/function names, **module documentation**) -- Refactoring opportunities (cyclomatic complexity, nesting) -- Warnings (unused operations, unsafe operations) - -**Documentation Enforcement:** - -- ✅ `Credo.Check.Readability.ModuleDoc` - **ENABLED** (as of November 2025) -- All modules require `@moduledoc` documentation -- Current coverage: 51 @moduledoc declarations across 47 modules (100% core modules) -- CI pipeline enforces documentation standards - -**Address Credo Issues:** - -```elixir -# Before -def complex_function(user, data, opts) do - if user.admin? do - if data.valid? do - if opts[:force] do - # deeply nested logic - end - end - end -end - -# After - flatten with guard clauses -def complex_function(user, _data, _opts) when not user.admin?, - do: {:error, :unauthorized} - -def complex_function(_user, data, _opts) when not data.valid?, - do: {:error, :invalid_data} - -def complex_function(_user, data, opts) do - if opts[:force] do - process_data(data) - else - validate_and_process(data) - end -end -``` - -### 3.9 Security: Sobelow - -**Run Security Analysis:** - -```bash -# Security audit -mix sobelow --config - -# With verbose output -mix sobelow --config --verbose -``` - -**Security Best Practices:** - -- Never commit secrets to version control -- Use environment variables for sensitive configuration -- Validate and sanitize all user inputs -- Use parameterized queries (Ecto handles this) -- Keep dependencies updated - -### 3.10 Dependency Auditing & Updates - -**Regular Security Audits:** - -```bash -# Audit dependencies for security vulnerabilities -mix deps.audit - -# Audit hex packages -mix hex.audit - -# Security scan with Sobelow -mix sobelow --config -``` - -**Update Dependencies:** - -```bash -# Update all dependencies -mix deps.update --all - -# Update specific dependency -mix deps.update phoenix - -# Check for outdated packages -mix hex.outdated -``` - -### 3.11 Email: Swoosh - -**Mailer Configuration:** - -```elixir -defmodule Mv.Mailer do - use Swoosh.Mailer, otp_app: :mv -end -``` - -**Sending Emails:** - -```elixir -defmodule Mv.Accounts.WelcomeEmail do - use Phoenix.Swoosh, template_root: "lib/mv_web/templates" - import Swoosh.Email - - def send(user) do - new() - |> to({user.name, user.email}) - |> from({"Mila", "noreply@mila.example.com"}) - |> subject("Welcome to Mila!") - |> render_body("welcome.html", %{user: user}) - |> Mv.Mailer.deliver() - end -end -``` - -### 3.12 Internationalization: Gettext - -**Define Translations:** - -```elixir -# In LiveView or controller -gettext("Welcome to Mila") - -# With interpolation -gettext("Hello, %{name}!", name: user.name) - -# Domain-specific translations -dgettext("auth", "Sign in with email") -``` - -**Extract and Merge:** - -```bash -# Extract new translatable strings -mix gettext.extract - -# Merge into existing translations -mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete -``` - -### 3.13 Task Runner: Just - -**Common Commands:** - -```bash -# Start development environment -just run - -# Run tests -just test - -# Run linter -just lint - -# Run security audit -just audit - -# Reset database -just reset-database - -# Format code -just format - -# Regenerate migrations -just regen-migrations migration_name -``` - -**Define Custom Tasks:** - -Edit `Justfile` for project-specific tasks: - -```makefile -# Example custom task -setup-dev: install-dependencies start-database migrate-database - mix phx.gen.secret - @echo "Development environment ready!" -``` - ---- - -## 4. Testing Standards - -### 4.1 Test Setup and Organization - -**Test Directory Structure:** - -Mirror the `lib/` directory structure in `test/`: - -``` -test/ -├── accounts/ # Tests for Accounts domain -│ ├── user_test.exs -│ ├── email_sync_test.exs -│ └── ... -├── membership/ # Tests for Membership domain -│ ├── member_test.exs -│ └── ... -├── mv_web/ # Tests for Web layer -│ ├── controllers/ -│ ├── live/ -│ └── components/ -└── support/ # Test helpers - ├── conn_case.ex # Controller test setup - └── data_case.ex # Database test setup -``` - -**Test File Naming:** - -- Use `_test.exs` suffix for all test files -- Match the module name: `user.ex` → `user_test.exs` -- Use descriptive names for integration tests: `user_member_relationship_test.exs` - -### 4.2 ExUnit Basics - -**Test Module Structure:** - -```elixir -defmodule Mv.Membership.MemberTest do - use Mv.DataCase, async: true # async: true for parallel execution - - alias Mv.Membership.Member - - describe "create_member/1" do - test "creates a member with valid attributes" do - attrs = %{ - first_name: "John", - last_name: "Doe", - email: "john@example.com" - } - - assert {:ok, %Member{} = member} = Mv.Membership.create_member(attrs) - assert member.first_name == "John" - assert member.email == "john@example.com" - end - - test "returns error with invalid attributes" do - attrs = %{first_name: nil} - - assert {:error, _error} = Mv.Membership.create_member(attrs) - end - end - - describe "list_members/0" do - setup do - # Setup code for this describe block - {:ok, member: create_test_member()} - end - - test "returns all members", %{member: member} do - members = Mv.Membership.list_members() - assert length(members) == 1 - assert List.first(members).id == member.id - end - end -end -``` - -### 4.3 Test Types - -#### 4.3.1 Unit Tests - -Test individual functions and modules in isolation: - -```elixir -defmodule Mv.Membership.EmailTest do - use ExUnit.Case, async: true - - alias Mv.Membership.Email - - describe "valid?/1" do - test "returns true for valid email" do - assert Email.valid?("user@example.com") - end - - test "returns false for invalid email" do - refute Email.valid?("invalid-email") - refute Email.valid?("missing-at-sign.com") - end - end -end -``` - -#### 4.3.2 Integration Tests - -Test interactions between multiple modules or systems: - -```elixir -defmodule Mv.Accounts.UserMemberRelationshipTest do - use Mv.DataCase, async: true - - alias Mv.Accounts.User - alias Mv.Membership.Member - - describe "user-member relationship" do - test "creating a user automatically creates a member" do - attrs = %{ - email: "test@example.com", - password: "SecurePassword123" - } - - assert {:ok, user} = Mv.Accounts.create_user(attrs) - assert {:ok, member} = Mv.Membership.get_member_by_user_id(user.id) - assert member.email == user.email - end - - test "deleting a user cascades to member" do - {:ok, user} = create_user() - {:ok, member} = Mv.Membership.get_member_by_user_id(user.id) - - assert :ok = Mv.Accounts.destroy_user(user) - assert {:error, :not_found} = Mv.Membership.get_member(member.id) - end - end -end -``` - -#### 4.3.3 Controller Tests - -Test HTTP endpoints: - -```elixir -defmodule MvWeb.PageControllerTest do - use MvWeb.ConnCase, async: true - - test "GET /", %{conn: conn} do - conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Welcome to Mila" - end -end -``` - -#### 4.3.4 LiveView Tests - -Test LiveView interactions: - -```elixir -defmodule MvWeb.MemberLive.IndexTest do - use MvWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - - setup do - member = create_test_member() - %{member: member} - end - - test "displays list of members", %{conn: conn, member: member} do - {:ok, view, html} = live(conn, ~p"/members") - - assert html =~ "Members" - assert html =~ member.first_name - end - - test "deletes member", %{conn: conn, member: member} do - {:ok, view, _html} = live(conn, ~p"/members") - - assert view - |> element("#member-#{member.id} a", "Delete") - |> render_click() - - refute has_element?(view, "#member-#{member.id}") - end -end -``` - -#### 4.3.5 Component Tests - -Test function components: - -```elixir -defmodule MvWeb.Components.SearchBarComponentTest do - use MvWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - import MvWeb.Components.SearchBarComponent - - test "renders search input" do - assigns = %{search_query: "", id: "search"} - - html = - render_component(&search_bar/1, assigns) - - assert html =~ "input" - assert html =~ ~s(type="search") - end -end -``` - -### 4.4 Test Helpers and Fixtures - -**Create Test Helpers:** - -```elixir -# test/support/fixtures.ex -defmodule Mv.Fixtures do - def member_fixture(attrs \\ %{}) do - default_attrs = %{ - first_name: "Test", - last_name: "User", - email: "test#{System.unique_integer()}@example.com" - } - - {:ok, member} = - default_attrs - |> Map.merge(attrs) - |> Mv.Membership.create_member() - - member - end -end -``` - -**Use Setup Blocks:** - -```elixir -describe "with authenticated user" do - setup %{conn: conn} do - user = create_user() - conn = log_in_user(conn, user) - %{conn: conn, user: user} - end - - test "can access protected page", %{conn: conn} do - conn = get(conn, ~p"/profile") - assert html_response(conn, 200) =~ "Profile" - end -end -``` - -### 4.5 Database Testing with Sandbox - -**Use Ecto Sandbox for Isolation:** - -```elixir -# test/test_helper.exs -ExUnit.start() -Ecto.Adapters.SQL.Sandbox.mode(Mv.Repo, :manual) -``` - -```elixir -# test/support/data_case.ex -defmodule Mv.DataCase do - use ExUnit.CaseTemplate - - using do - quote do - import Ecto - import Ecto.Changeset - import Ecto.Query - import Mv.DataCase - - alias Mv.Repo - end - end - - setup tags do - pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async]) - on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) - :ok - end -end -``` - -### 4.6 Test Coverage - -**Run Tests with Coverage:** - -```bash -# Run tests -mix test - -# Run with coverage -mix test --cover - -# Run specific test file -mix test test/membership/member_test.exs - -# Run specific test line -mix test test/membership/member_test.exs:42 -``` - -**Coverage Goals:** - -- Aim for >80% overall coverage -- 100% coverage for critical business logic -- Focus on meaningful tests, not just coverage numbers - -### 4.7 Testing Best Practices - -**Descriptive Test Names:** - -```elixir -# Good - describes what is being tested -test "creates a member with valid email address" -test "returns error when email is already taken" -test "sends welcome email after successful registration" - -# Avoid - vague or generic -test "member creation" -test "error case" -test "test 1" -``` - -**Arrange-Act-Assert Pattern:** - -```elixir -test "updates member email" do - # Arrange - set up test data - member = member_fixture() - new_email = "new@example.com" - - # Act - perform the action - {:ok, updated_member} = Mv.Membership.update_member(member, %{email: new_email}) - - # Assert - verify results - assert updated_member.email == new_email -end -``` - -**Test One Thing Per Test:** - -```elixir -# Good - focused test -test "validates email format" do - attrs = %{email: "invalid-email"} - assert {:error, _} = Mv.Membership.create_member(attrs) -end - -test "requires email to be present" do - attrs = %{email: nil} - assert {:error, _} = Mv.Membership.create_member(attrs) -end - -# Avoid - testing multiple things -test "validates email" do - # Tests both format and presence - assert {:error, _} = Mv.Membership.create_member(%{email: nil}) - assert {:error, _} = Mv.Membership.create_member(%{email: "invalid"}) -end -``` - -**Use describe Blocks for Organization:** - -```elixir -describe "create_member/1" do - test "success case" do - # ... - end - - test "error case" do - # ... - end -end - -describe "update_member/2" do - test "success case" do - # ... - end -end -``` - -**Avoid Testing Implementation Details:** - -```elixir -# Good - test behavior -test "member can be created with valid attributes" do - attrs = valid_member_attrs() - assert {:ok, %Member{}} = Mv.Membership.create_member(attrs) -end - -# Avoid - testing internal implementation -test "create_member calls Ash.create with correct params" do - # This is too coupled to implementation -end -``` - -**Keep Tests Fast:** - -- Use `async: true` when possible -- Avoid unnecessary database interactions -- Mock external services -- Use fixtures efficiently - ---- - -## 5. Security Guidelines - -### 5.1 Authentication & Authorization - -**Use AshAuthentication:** - -```elixir -# Authentication is configured at the resource level -authentication do - strategies do - password :password do - identity_field :email - hashed_password_field :hashed_password - end - - oauth2 :rauthy do - # OIDC configuration - end - end -end -``` - -**Implement Authorization Policies:** - -```elixir -policies do - # Default deny - policy action_type(:*) do - authorize_if always() - end - - # Specific permissions - policy action_type([:read, :update]) do - authorize_if relates_to_actor_via(:user) - end - - policy action_type(:destroy) do - authorize_if actor_attribute_equals(:role, :admin) - end -end -``` - -### 5.2 Password Security - -**Use bcrypt for Password Hashing:** - -```elixir -# Configured in AshAuthentication resource -password :password do - identity_field :email - hashed_password_field :hashed_password - hash_provider AshAuthentication.BcryptProvider - - confirmation_required? true -end -``` - -**Password Requirements:** - -- Minimum 12 characters -- Mix of uppercase, lowercase, numbers (enforced by validation) -- Use `bcrypt_elixir` for hashing (never store plain text passwords) - -### 5.3 Input Validation & Sanitization - -**Validate All User Input:** - -```elixir -attributes do - attribute :email, :string do - allow_nil? false - public? true - end -end - -validations do - validate present(:email) - validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/) -end -``` - -**SQL Injection Prevention:** - -Ecto and Ash handle parameterized queries automatically: - -```elixir -# Safe - parameterized query -Ash.Query.filter(Member, email == ^user_email) - -# Avoid raw SQL when possible -Ecto.Adapters.SQL.query(Mv.Repo, "SELECT * FROM members WHERE email = $1", [user_email]) -``` - -### 5.4 CSRF Protection - -**Phoenix Handles CSRF Automatically:** - -```heex - -<.form for={@form} phx-submit="save"> - - -``` - -**Configure in Endpoint:** - -```elixir -# lib/mv_web/endpoint.ex -plug Plug.Session, - store: :cookie, - key: "_mv_key", - signing_salt: "secret" - -plug :protect_from_forgery -``` - -### 5.5 Secrets Management - -**Never Commit Secrets:** - -```bash -# .gitignore should include: -.env -.env.* -!.env.example -``` - -**Use Environment Variables:** - -```elixir -# config/runtime.exs -config :mv, :rauthy, - client_id: System.get_env("OIDC_CLIENT_ID") || "mv", - client_secret: System.get_env("OIDC_CLIENT_SECRET"), - base_url: System.get_env("OIDC_BASE_URL") -``` - -**Generate Secure Secrets:** - -```bash -# Generate secret key base -mix phx.gen.secret - -# Generate token signing secret -mix phx.gen.secret -``` - -### 5.6 Security Headers - -**Configure Security Headers:** - -```elixir -# lib/mv_web/endpoint.ex -plug Plug.Static, - at: "/", - from: :mv, - gzip: false, - only: MvWeb.static_paths(), - headers: %{ - "x-content-type-options" => "nosniff", - "x-frame-options" => "SAMEORIGIN", - "x-xss-protection" => "1; mode=block" - } -``` - -### 5.7 Dependency Security - -- **Use Renovate for automated dependency updates** -- **Review changelogs before updating dependencies** -- **Test thoroughly after updates** -- **Run regular audits** (see section 3.10 for audit commands) - -### 5.8 Logging & Monitoring - -**Sanitize Logs:** - -```elixir -# Don't log sensitive information -Logger.info("User login attempt", user_id: user.id) - -# Avoid -Logger.info("User login attempt", user: inspect(user)) # May contain password -``` - -**Configure Logger:** - -```elixir -# config/config.exs -config :logger, :default_formatter, - format: "$time $metadata[$level] $message\n", - metadata: [:request_id, :user_id] -``` - ---- - -## 6. Performance Best Practices - -### 6.1 Database Performance - -**Indexing:** - -```elixir -postgres do - table "members" - repo Mv.Repo - - # Add indexes for frequently queried fields - index [:email], unique: true - index [:last_name] - index [:created_at] -end -``` - -**Avoid N+1 Queries:** - -```elixir -# Good - preload relationships -members = - Member - |> Ash.Query.load([:properties, :user]) - |> Mv.Membership.list_members!() - -# Avoid - causes N+1 -members = Mv.Membership.list_members!() -Enum.map(members, fn member -> - properties = Ash.load!(member, :properties) # N queries! -end) -``` - -**Pagination:** - -```elixir -# Use keyset pagination (configured as default in Ash) -Ash.Query.page(Member, offset: 0, limit: 50) -``` - -**Batch Operations:** - -```elixir -# Use bulk operations for multiple records -Ash.bulk_create([member1_attrs, member2_attrs, member3_attrs], Member, :create) -``` - -### 6.2 LiveView Performance - -**Optimize Assigns:** - -```elixir -# Good - only assign what's needed -def mount(_params, _session, socket) do - {:ok, assign(socket, members_count: get_count())} -end - -# Avoid - assigning large collections unnecessarily -def mount(_params, _session, socket) do - {:ok, assign(socket, all_members: list_all_members())} # Heavy! -end -``` - -**Use Temporary Assigns:** - -```elixir -# For data that's only needed for rendering -def handle_event("load_report", _, socket) do - report_data = generate_large_report() - {:noreply, assign(socket, report: report_data) |> assign(:report, temporary_assigns: [:report])} -end -``` - -**Stream Collections:** - -```elixir -# For large collections -def mount(_params, _session, socket) do - {:ok, stream(socket, :members, list_members())} -end - -def render(assigns) do - ~H""" -
-
- <%= member.name %> -
-
- """ -end -``` - -### 6.3 Caching Strategies - -**Function-Level Caching:** - -```elixir -defmodule Mv.Cache do - use GenServer - - def get_or_compute(key, compute_fn) do - case get(key) do - nil -> - value = compute_fn.() - put(key, value) - value - - value -> - value - end - end -end -``` - -**ETS for In-Memory Cache:** - -```elixir -# In application.ex -:ets.new(:mv_cache, [:named_table, :public, read_concurrency: true]) - -# Usage -:ets.insert(:mv_cache, {"key", "value"}) -:ets.lookup(:mv_cache, "key") -``` - -### 6.4 Async Processing - -**Background Jobs (Future):** - -```elixir -# When Oban is added -defmodule Mv.Workers.EmailWorker do - use Oban.Worker - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"user_id" => user_id}}) do - user = Mv.Accounts.get_user!(user_id) - Mv.Mailer.send_welcome_email(user) - :ok - end -end - -# Enqueue job -%{user_id: user.id} -|> Mv.Workers.EmailWorker.new() -|> Oban.insert() -``` - -**Task Async for Concurrent Operations:** - -```elixir -def gather_dashboard_data do - tasks = [ - Task.async(fn -> get_member_count() end), - Task.async(fn -> get_recent_registrations() end), - Task.async(fn -> get_payment_status() end) - ] - - [member_count, recent, payments] = Task.await_many(tasks) - - %{ - member_count: member_count, - recent: recent, - payments: payments - } -end -``` - -### 6.5 Profiling & Monitoring - -**Use :observer:** - -```elixir -# In iex session -:observer.start() -``` - -**Use Telemetry:** - -```elixir -# Attach telemetry handlers -:telemetry.attach( - "query-duration", - [:mv, :repo, :query], - fn event, measurements, metadata, _config -> - Logger.info("Query took #{measurements.total_time}ms") - end, - nil -) -``` - ---- - -## 7. Documentation Standards - -### 7.1 Module Documentation - -**Use @moduledoc:** - -```elixir -defmodule Mv.Membership.Member do - @moduledoc """ - Represents a club member with their personal information and membership status. - - Members can have custom properties defined by the club administrators. - Each member is optionally linked to a user account for self-service access. - - ## Examples - - iex> Mv.Membership.create_member(%{first_name: "John", last_name: "Doe", email: "john@example.com"}) - {:ok, %Mv.Membership.Member{}} - - """ -end -``` - -**Module Documentation Should Include:** - -- Purpose of the module -- Key responsibilities -- Usage examples -- Related modules - -### 7.2 Function Documentation - -**Use @doc:** - -```elixir -@doc """ -Creates a new member with the given attributes. - -## Parameters - - - `attrs` - A map of member attributes including: - - `:first_name` (required) - The member's first name - - `:last_name` (required) - The member's last name - - `:email` (required) - The member's email address - -## Returns - - - `{:ok, member}` - Successfully created member - - `{:error, error}` - Validation or creation error - -## Examples - - iex> Mv.Membership.create_member(%{ - ...> first_name: "Jane", - ...> last_name: "Smith", - ...> email: "jane@example.com" - ...> }) - {:ok, %Mv.Membership.Member{first_name: "Jane"}} - - iex> Mv.Membership.create_member(%{first_name: nil}) - {:error, %Ash.Error.Invalid{}} - -""" -@spec create_member(map()) :: {:ok, Member.t()} | {:error, Ash.Error.t()} -def create_member(attrs) do - # Implementation -end -``` - -### 7.3 Type Specifications - -**Use @spec for Function Signatures:** - -```elixir -@spec create_member(map()) :: {:ok, Member.t()} | {:error, Ash.Error.t()} -def create_member(attrs) - -@spec list_members(keyword()) :: [Member.t()] -def list_members(opts \\ []) - -@spec get_member!(String.t()) :: Member.t() | no_return() -def get_member!(id) -``` - -### 7.4 Code Comments - -**When to Comment:** - -```elixir -# Good - explain WHY, not WHAT -def calculate_dues(member) do - # Annual dues are prorated based on join date to be fair to mid-year joiners - months_active = calculate_months_active(member) - @annual_dues * (months_active / 12) -end - -# Avoid - stating the obvious -def calculate_dues(member) do - # Calculate the dues - months_active = calculate_months_active(member) # Get months active - @annual_dues * (months_active / 12) # Multiply annual dues by fraction -end -``` - -**Complex Logic:** - -```elixir -def sync_member_email(member, user) do - # Email synchronization priority: - # 1. User email is the source of truth (authenticated account) - # 2. Member email is updated to match user email - # 3. Preserve member email history for audit purposes - - if member.email != user.email do - # Archive old email before updating - archive_member_email(member, member.email) - update_member(member, %{email: user.email}) - end -end -``` - -### 7.5 README and Project Documentation - -**Keep README.md Updated:** - -- Installation instructions -- Development setup -- Running tests -- Deployment guide -- Contributing guidelines - -**Additional Documentation:** - -- `docs/` directory for detailed guides -- Architecture decision records (ADRs) -- API documentation (generated with ExDoc) - -**Generate Documentation:** - -```bash -# Generate HTML documentation -mix docs - -# View documentation -open doc/index.html -``` - -### 7.6 Changelog - -**Maintain CHANGELOG.md:** - -```markdown -# Changelog - -## [Unreleased] - -### Added -- Member custom properties feature -- Email synchronization between user and member - -### Changed -- Updated Phoenix to 1.8.0 - -### Fixed -- Email uniqueness validation bug - -## [0.1.0] - 2025-01-15 - -### Added -- Initial release -- Basic member management -- OIDC authentication -``` - ---- - -## 8. Git Workflow - -### 8.1 Branching Strategy - -**Main Branches:** - -- `main` - Production-ready code - -**Feature Branches:** - -```bash -# Create feature branch -git checkout -b feature/member-custom-properties - -# Work on feature -git add . -git commit -m "Add custom properties to members" - -# Push to remote -git push origin feature/member-custom-properties -``` - -### 8.2 Commit Messages - -**Format:** - -``` -: - - - -