diff --git a/.credo.exs b/.credo.exs index d871572..4eddee8 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, []} + {Credo.Check.Warning.WrongTestFileExtension, []}, + # Module documentation check (enabled after adding @moduledoc to all modules) + {Credo.Check.Readability.ModuleDoc, []} ], 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 new file mode 100644 index 0000000..4a82edb --- /dev/null +++ b/CODE_GUIDELINES.md @@ -0,0 +1,2578 @@ +# 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:** + +``` +: + + + +