Add section explaining when and how to use system actor for systemic operations. Include examples and distinction between user mode and system mode.
2696 lines
60 KiB
Markdown
2696 lines
60 KiB
Markdown
# 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
|
|
│ ├── custom_field_value.ex # Custom field value resource
|
|
│ ├── custom_field.ex # CustomFieldValue type resource
|
|
│ ├── setting.ex # Global settings (singleton resource)
|
|
│ └── email.ex # Email custom type
|
|
├── membership_fees/ # MembershipFees domain
|
|
│ ├── membership_fees.ex # Domain definition
|
|
│ ├── membership_fee_type.ex # Membership fee type resource
|
|
│ ├── membership_fee_cycle.ex # Membership fee cycle resource
|
|
│ └── changes/ # Ash changes for membership fees
|
|
├── mv/authorization/ # Authorization domain
|
|
│ ├── authorization.ex # Domain definition
|
|
│ ├── role.ex # Role resource
|
|
│ ├── permission_sets.ex # Hardcoded permission sets
|
|
│ └── checks/ # Authorization checks
|
|
├── 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/
|
|
│ ├── membership_fees/ # Membership fee business logic
|
|
│ │ ├── cycle_generator.ex # Cycle generation algorithm
|
|
│ │ └── calendar_cycles.ex # Calendar cycle calculations
|
|
│ ├── helpers.ex # Shared helper functions (ash_actor_opts)
|
|
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix)
|
|
│ ├── 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
|
|
│ │ ├── sidebar.ex
|
|
│ │ └── root.html.heex
|
|
│ ├── controllers/ # HTTP controllers
|
|
│ │ ├── auth_controller.ex
|
|
│ │ ├── page_controller.ex
|
|
│ │ ├── locale_controller.ex
|
|
│ │ ├── error_html.ex
|
|
│ │ ├── error_json.ex
|
|
│ │ └── page_html/
|
|
│ ├── helpers/ # Web layer helper modules
|
|
│ │ ├── member_helpers.ex # Member display utilities
|
|
│ │ ├── membership_fee_helpers.ex # Membership fee formatting
|
|
│ │ ├── date_formatter.ex # Date formatting utilities
|
|
│ │ └── field_type_formatter.ex # Field type display formatting
|
|
│ ├── live/ # LiveView modules
|
|
│ │ ├── components/ # LiveView-specific components
|
|
│ │ │ ├── search_bar_component.ex
|
|
│ │ │ └── sort_header_component.ex
|
|
│ │ ├── member_live/ # Member CRUD LiveViews
|
|
│ │ ├── custom_field_value_live/ # CustomFieldValue CRUD LiveViews
|
|
│ │ ├── custom_field_live/
|
|
│ │ ├── user_live/ # User management LiveViews
|
|
│ │ ├── role_live/ # Role management LiveViews
|
|
│ │ ├── membership_fee_type_live/ # Membership fee type LiveViews
|
|
│ │ ├── membership_fee_settings_live.ex # Membership fee settings
|
|
│ │ ├── global_settings_live.ex # Global settings
|
|
│ │ └── contribution_type_live/ # Contribution types (mock-up)
|
|
│ ├── auth_overrides.ex # AshAuthentication overrides
|
|
│ ├── endpoint.ex # Phoenix endpoint
|
|
│ ├── gettext.ex # I18n configuration
|
|
│ ├── live_helpers.ex # LiveView lifecycle hooks and 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`, `Mv.Membership`, `Mv.MembershipFees`, and `Mv.Authorization`
|
|
- **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"""
|
|
<button class="btn btn-primary" {@rest}>
|
|
<%= render_slot(@inner_block) %>
|
|
</button>
|
|
"""
|
|
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"""
|
|
<div id={@id} class="card">
|
|
<h2 :if={@title}><%= @title %></h2>
|
|
<%= render_slot(@inner_block) %>
|
|
</div>
|
|
"""
|
|
end
|
|
```
|
|
|
|
### 3.3 System Actor Pattern
|
|
|
|
**When to Use System Actor:**
|
|
|
|
Some operations must always run regardless of user permissions. These are **systemic operations** that are mandatory side effects:
|
|
|
|
- **Email synchronization** (Member ↔ User)
|
|
- **Email uniqueness validation** (data integrity requirement)
|
|
- **Cycle generation** (if defined as mandatory side effect)
|
|
- **Background jobs**
|
|
- **Seeds**
|
|
|
|
**Implementation:**
|
|
|
|
Use `Mv.Helpers.SystemActor.get_system_actor/0` for all systemic operations:
|
|
|
|
```elixir
|
|
# Good - Email sync uses system actor
|
|
def get_linked_member(user) do
|
|
system_actor = SystemActor.get_system_actor()
|
|
opts = Helpers.ash_actor_opts(system_actor)
|
|
|
|
case Ash.get(Mv.Membership.Member, id, opts) do
|
|
{:ok, member} -> member
|
|
{:error, _} -> nil
|
|
end
|
|
end
|
|
|
|
# Bad - Using user actor for systemic operation
|
|
def get_linked_member(user, actor) do
|
|
opts = Helpers.ash_actor_opts(actor) # May fail if user lacks permissions!
|
|
# ...
|
|
end
|
|
```
|
|
|
|
**System Actor Details:**
|
|
|
|
- System actor is a user with admin role (email: "system@mila.local")
|
|
- Cached in Agent for performance
|
|
- Falls back to admin user from seeds if system user doesn't exist
|
|
- Should NEVER be used for user-initiated actions (only systemic operations)
|
|
|
|
**User Mode vs System Mode:**
|
|
|
|
- **User Mode**: User-initiated actions use the actual user actor, policies are enforced
|
|
- **System Mode**: Systemic operations use system actor, bypass user permissions
|
|
|
|
### 3.4 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(:custom_field_values)
|
|
|> 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, :custom_field_values)
|
|
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
|
|
<!-- Good - use utility classes -->
|
|
<div class="card bg-base-100 shadow-xl">
|
|
<div class="card-body">
|
|
<h2 class="card-title">Member Name</h2>
|
|
<p class="text-sm text-gray-600">Email: member@example.com</p>
|
|
<div class="card-actions justify-end">
|
|
<button class="btn btn-primary">Edit</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Avoid - custom CSS for standard patterns -->
|
|
<div class="custom-member-card">
|
|
<h2 class="custom-title">Member Name</h2>
|
|
<!-- ... -->
|
|
</div>
|
|
```
|
|
|
|
**Responsive Design:**
|
|
|
|
```heex
|
|
<!-- Use Tailwind's responsive prefixes -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<%= for member <- @members do %>
|
|
<.member_card member={member} />
|
|
<% end %>
|
|
</div>
|
|
```
|
|
|
|
**DaisyUI Components:**
|
|
|
|
```heex
|
|
<!-- Leverage DaisyUI component classes -->
|
|
<!-- Note: Navbar has been replaced with Sidebar (see lib/mv_web/components/layouts/sidebar.ex) -->
|
|
<div class="drawer lg:drawer-open">
|
|
<input id="drawer-toggle" type="checkbox" class="drawer-toggle" />
|
|
<div class="drawer-content">
|
|
<!-- Page content -->
|
|
</div>
|
|
<div class="drawer-side">
|
|
<label for="drawer-toggle" class="drawer-overlay"></label>
|
|
<aside class="w-64 min-h-full bg-base-200">
|
|
<!-- Sidebar content -->
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**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
|
|
|
|
# Use HasPermission check for role-based authorization
|
|
policy action_type([:read, :update, :create, :destroy]) do
|
|
authorize_if Mv.Authorization.Checks.HasPermission
|
|
end
|
|
end
|
|
```
|
|
|
|
**Actor Handling in LiveViews:**
|
|
|
|
Always use the `current_actor/1` helper for consistent actor access:
|
|
|
|
```elixir
|
|
# In LiveView modules
|
|
import MvWeb.LiveHelpers, only: [current_actor: 1, ash_actor_opts: 1, submit_form: 3]
|
|
|
|
def mount(_params, _session, socket) do
|
|
actor = current_actor(socket)
|
|
|
|
case Ash.read(Mv.Membership.Member, ash_actor_opts(actor)) do
|
|
{:ok, members} ->
|
|
{:ok, assign(socket, :members, members)}
|
|
{:error, error} ->
|
|
{:ok, put_flash(socket, :error, "Failed to load members")}
|
|
end
|
|
end
|
|
|
|
def handle_event("save", %{"member" => params}, socket) do
|
|
actor = current_actor(socket)
|
|
form = AshPhoenix.Form.for_create(Mv.Membership.Member, :create)
|
|
|
|
case submit_form(form, params, actor) do
|
|
{:ok, member} ->
|
|
{:noreply, push_navigate(socket, to: ~p"/members/#{member.id}")}
|
|
{:error, form} ->
|
|
{:noreply, assign(socket, :form, form)}
|
|
end
|
|
end
|
|
```
|
|
|
|
**Never use bang calls (`Ash.read!`, `Ash.get!`) without error handling:**
|
|
|
|
```elixir
|
|
# Bad - will crash on authorization errors
|
|
members = Ash.read!(Mv.Membership.Member, actor: actor)
|
|
|
|
# Good - proper error handling
|
|
case Ash.read(Mv.Membership.Member, actor: actor) do
|
|
{:ok, members} -> # success
|
|
{:error, %Ash.Error.Forbidden{}} -> # handle authorization error
|
|
{:error, error} -> # handle other errors
|
|
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
|
|
<!-- CSRF token automatically included in forms -->
|
|
<.form for={@form} phx-submit="save">
|
|
<!-- form fields -->
|
|
</.form>
|
|
```
|
|
|
|
**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([:custom_field_values, :user])
|
|
|> Mv.Membership.list_members!()
|
|
|
|
# Avoid - causes N+1
|
|
members = Mv.Membership.list_members!()
|
|
Enum.map(members, fn member ->
|
|
custom_field_values = Ash.load!(member, :custom_field_values) # 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"""
|
|
<div id="members" phx-update="stream">
|
|
<div :for={{id, member} <- @streams.members} id={id}>
|
|
<%= member.name %>
|
|
</div>
|
|
</div>
|
|
"""
|
|
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_field_values 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_field_values 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-custom_field_values
|
|
|
|
# Work on feature
|
|
git add .
|
|
git commit -m "Add custom_field_values to members"
|
|
|
|
# Push to remote
|
|
git push origin feature/member-custom-custom_field_values
|
|
```
|
|
|
|
### 8.2 Commit Messages
|
|
|
|
**Format:**
|
|
|
|
```
|
|
<type>: <subject>
|
|
|
|
<body>
|
|
|
|
<footer>
|
|
```
|
|
|
|
**Types:**
|
|
|
|
- `feat:` New feature
|
|
- `fix:` Bug fix
|
|
- `docs:` Documentation changes
|
|
- `style:` Code style changes (formatting)
|
|
- `refactor:` Code refactoring
|
|
- `test:` Adding or updating tests
|
|
- `chore:` Maintenance tasks
|
|
|
|
**Examples:**
|
|
|
|
```
|
|
feat: add email synchronization for members
|
|
|
|
Implement automatic email sync between user accounts and member records.
|
|
When a user updates their email, the associated member record is updated.
|
|
|
|
Closes #123
|
|
```
|
|
|
|
```
|
|
fix: resolve N+1 query in member list
|
|
|
|
Preload custom_field_values relationship when loading members to avoid N+1 queries.
|
|
|
|
Performance improvement: reduced query count from 100+ to 2.
|
|
```
|
|
|
|
### 8.3 Code Reviews
|
|
|
|
**Before Creating PR:**
|
|
|
|
- Run `mix format`
|
|
- Run `mix credo`
|
|
- Run `mix test`
|
|
- Run `mix sobelow --config`
|
|
- Update documentation if needed
|
|
|
|
**PR Description Should Include:**
|
|
|
|
- What changed
|
|
- Why it changed
|
|
- How to test it
|
|
- Screenshots (for UI changes)
|
|
- Related issues
|
|
|
|
---
|
|
|
|
## 9. Deployment
|
|
|
|
### 9.1 Environment Configuration
|
|
|
|
**Production Checklist:**
|
|
|
|
- [ ] Set `SECRET_KEY_BASE` (use `mix phx.gen.secret`)
|
|
- [ ] Set `TOKEN_SIGNING_SECRET`
|
|
- [ ] Configure `DATABASE_URL`
|
|
- [ ] Set `PHX_HOST`
|
|
- [ ] Configure OIDC credentials
|
|
- [ ] Set up SMTP for email
|
|
- [ ] Enable SSL/TLS
|
|
- [ ] Configure monitoring
|
|
|
|
### 9.2 Database Migrations
|
|
|
|
```bash
|
|
# In production Docker container
|
|
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
|
|
```
|
|
|
|
### 9.3 Building Docker Image
|
|
|
|
```bash
|
|
# Build production image
|
|
docker build -t mitgliederverwaltung .
|
|
|
|
# Or use Just
|
|
just build-docker-container
|
|
```
|
|
|
|
### 9.4 Health Checks
|
|
|
|
**Implement Health Check Endpoint:**
|
|
|
|
```elixir
|
|
# lib/mv_web/controllers/health_controller.ex
|
|
defmodule MvWeb.HealthController do
|
|
use MvWeb, :controller
|
|
|
|
def index(conn, _params) do
|
|
# Check database connectivity
|
|
case Ecto.Adapters.SQL.query(Mv.Repo, "SELECT 1", []) do
|
|
{:ok, _} -> json(conn, %{status: "healthy"})
|
|
{:error, _} -> conn |> put_status(503) |> json(%{status: "unhealthy"})
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Additional Resources
|
|
|
|
### 10.1 Official Documentation
|
|
|
|
- **Elixir:** https://elixir-lang.org/docs.html
|
|
- **Phoenix:** https://hexdocs.pm/phoenix/
|
|
- **Ash Framework:** https://hexdocs.pm/ash/
|
|
- **LiveView:** https://hexdocs.pm/phoenix_live_view/
|
|
- **Ecto:** https://hexdocs.pm/ecto/
|
|
- **Tailwind CSS:** https://tailwindcss.com/docs
|
|
- **DaisyUI:** https://daisyui.com/
|
|
|
|
### 10.2 Books & Guides
|
|
|
|
- Programming Elixir by Dave Thomas
|
|
- Phoenix in Action by Geoffrey Lessel
|
|
- Testing Elixir by Andrea Leopardi & Jeffrey Matthias
|
|
- Designing Elixir Systems with OTP by James Edward Gray II
|
|
|
|
### 10.3 Community
|
|
|
|
- Elixir Forum: https://elixirforum.com/
|
|
- Ash Framework Discord
|
|
- Phoenix Framework Slack
|
|
|
|
---
|
|
|
|
## 8. Accessibility Guidelines
|
|
|
|
Building accessible applications ensures that all users, including those with disabilities, can use our application effectively. The following guidelines follow WCAG 2.1 Level AA standards.
|
|
|
|
### 8.1 Semantic HTML
|
|
|
|
**Use Semantic Elements:**
|
|
|
|
```heex
|
|
<!-- Good - semantic HTML -->
|
|
<nav class="navbar">
|
|
<a href="/">Home</a>
|
|
</nav>
|
|
|
|
<main>
|
|
<article>
|
|
<h1>Member Details</h1>
|
|
<section>
|
|
<h2>Contact Information</h2>
|
|
<p>Email: <%= @member.email %></p>
|
|
</section>
|
|
</article>
|
|
</main>
|
|
|
|
<!-- Avoid - non-semantic divs -->
|
|
<div class="navigation">
|
|
<div class="link">Home</div>
|
|
</div>
|
|
```
|
|
|
|
### 8.2 ARIA Labels and Roles
|
|
|
|
**Use ARIA Attributes When Necessary:**
|
|
|
|
```heex
|
|
<!-- Icon-only buttons need labels -->
|
|
<button aria-label={gettext("Delete member")} phx-click="delete">
|
|
<.icon name="hero-trash" />
|
|
</button>
|
|
|
|
<!-- Loading states -->
|
|
<div role="status" aria-live="polite" aria-busy={@loading}>
|
|
<%= if @loading do %>
|
|
<span aria-label={gettext("Loading...")}>
|
|
<.icon name="hero-loading" class="animate-spin" />
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
|
|
<!-- Navigation landmarks -->
|
|
<nav aria-label={gettext("Main navigation")}>
|
|
<!-- navigation items -->
|
|
</nav>
|
|
```
|
|
|
|
### 8.3 Keyboard Navigation
|
|
|
|
**All Interactive Elements Must Be Keyboard Accessible:**
|
|
|
|
```heex
|
|
<!-- Good - keyboard accessible -->
|
|
<.link navigate={~p"/members"} class="btn">
|
|
Members
|
|
</.link>
|
|
|
|
<!-- Good - custom keyboard handler -->
|
|
<div
|
|
tabindex="0"
|
|
role="button"
|
|
phx-click="toggle"
|
|
phx-keydown="toggle"
|
|
phx-key="Enter"
|
|
aria-pressed={@expanded}>
|
|
Toggle
|
|
</div>
|
|
|
|
<!-- Avoid - div without keyboard support -->
|
|
<div phx-click="action">Click me</div>
|
|
```
|
|
|
|
**Tab Order:**
|
|
|
|
- Ensure logical tab order matches visual order
|
|
- Use `tabindex="0"` for custom interactive elements
|
|
- Use `tabindex="-1"` to programmatically focus (not in tab order)
|
|
- Never use positive `tabindex` values
|
|
|
|
### 8.4 Color and Contrast
|
|
|
|
**Ensure Sufficient Contrast:**
|
|
|
|
```elixir
|
|
# Tailwind classes with sufficient contrast (4.5:1 minimum)
|
|
# Good
|
|
<p class="text-gray-900 bg-white">High contrast text</p>
|
|
<p class="text-white bg-gray-900">Inverted high contrast</p>
|
|
|
|
# Avoid - insufficient contrast
|
|
<p class="text-gray-400 bg-gray-300">Low contrast text</p>
|
|
```
|
|
|
|
**Don't Rely Solely on Color:**
|
|
|
|
```heex
|
|
<!-- Good - color + icon + text -->
|
|
<div class="alert alert-error">
|
|
<.icon name="hero-exclamation-circle" />
|
|
<span><%= gettext("Error: Email is required") %></span>
|
|
</div>
|
|
|
|
<!-- Avoid - color only -->
|
|
<div class="text-red-500">
|
|
<%= gettext("Email is required") %>
|
|
</div>
|
|
```
|
|
|
|
### 8.5 Form Accessibility
|
|
|
|
**Label All Form Fields:**
|
|
|
|
```heex
|
|
<!-- Good - explicit labels -->
|
|
<.input
|
|
field={@form[:email]}
|
|
type="email"
|
|
label={gettext("Email Address")}
|
|
required
|
|
/>
|
|
|
|
<!-- Good - with helper text -->
|
|
<.input
|
|
field={@form[:password]}
|
|
type="password"
|
|
label={gettext("Password")}
|
|
help={gettext("Must be at least 12 characters")}
|
|
required
|
|
/>
|
|
```
|
|
|
|
**Error Messages:**
|
|
|
|
```heex
|
|
<!-- Accessible error messages -->
|
|
<.input
|
|
field={@form[:email]}
|
|
type="email"
|
|
label={gettext("Email")}
|
|
errors={@errors[:email]}
|
|
aria-describedby={@errors[:email] && "email-error"}
|
|
/>
|
|
<span :if={@errors[:email]} id="email-error" role="alert">
|
|
<%= @errors[:email] %>
|
|
</span>
|
|
```
|
|
|
|
**Required Fields:**
|
|
|
|
```heex
|
|
<!-- Mark required fields -->
|
|
<.input
|
|
field={@form[:first_name]}
|
|
label={gettext("First Name")}
|
|
required
|
|
aria-required="true"
|
|
/>
|
|
```
|
|
|
|
### 8.6 Focus Management
|
|
|
|
**Manage Focus in LiveView:**
|
|
|
|
```elixir
|
|
def handle_event("save", params, socket) do
|
|
case save_member(params) do
|
|
{:ok, member} ->
|
|
socket
|
|
|> put_flash(:info, gettext("Member saved successfully"))
|
|
|> push_navigate(to: ~p"/members/#{member}")
|
|
# Focus will move to the new page
|
|
|
|
{:error, changeset} ->
|
|
socket
|
|
|> assign(:form, to_form(changeset))
|
|
# Keep focus context
|
|
end
|
|
end
|
|
```
|
|
|
|
**Skip Links:**
|
|
|
|
```heex
|
|
<!-- Add skip link for keyboard users -->
|
|
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 focus:bg-white">
|
|
<%= gettext("Skip to main content") %>
|
|
</a>
|
|
|
|
<main id="main-content">
|
|
<!-- main content -->
|
|
</main>
|
|
```
|
|
|
|
### 8.7 Images and Media
|
|
|
|
**Alt Text for Images:**
|
|
|
|
```heex
|
|
<!-- Good - descriptive alt text -->
|
|
<img src="/images/member-photo.jpg" alt={gettext("Photo of %{name}", name: @member.name)} />
|
|
|
|
<!-- Decorative images -->
|
|
<img src="/images/decoration.svg" alt="" role="presentation" />
|
|
```
|
|
|
|
**Icons:**
|
|
|
|
```heex
|
|
<!-- Icons with meaning need labels -->
|
|
<.icon name="hero-check-circle" aria-label={gettext("Success")} />
|
|
|
|
<!-- Decorative icons -->
|
|
<.icon name="hero-sparkles" aria-hidden="true" />
|
|
```
|
|
|
|
### 8.8 Tables
|
|
|
|
**Accessible Data Tables:**
|
|
|
|
```heex
|
|
<table>
|
|
<caption><%= gettext("List of members") %></caption>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col"><%= gettext("Name") %></th>
|
|
<th scope="col"><%= gettext("Email") %></th>
|
|
<th scope="col"><%= gettext("Actions") %></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={member <- @members}>
|
|
<td><%= member.name %></td>
|
|
<td><%= member.email %></td>
|
|
<td>
|
|
<.link navigate={~p"/members/#{member}"} aria-label={gettext("View %{name}", name: member.name)}>
|
|
<%= gettext("View") %>
|
|
</.link>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
```
|
|
|
|
### 8.9 Live Regions
|
|
|
|
**Announce Dynamic Content:**
|
|
|
|
```heex
|
|
<!-- Search results announcement -->
|
|
<div role="status" aria-live="polite" aria-atomic="true">
|
|
<%= if @searched do %>
|
|
<span class="sr-only">
|
|
<%= ngettext("Found %{count} member", "Found %{count} members", @count) %>
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
|
|
<!-- Status messages -->
|
|
<div role="alert" aria-live="assertive">
|
|
<%= if @error do %>
|
|
<%= @error %>
|
|
<% end %>
|
|
</div>
|
|
```
|
|
|
|
### 8.10 Testing Accessibility
|
|
|
|
**Tools and Practices:**
|
|
|
|
```bash
|
|
# Browser DevTools
|
|
# - Chrome: Lighthouse Accessibility Audit
|
|
# - Firefox: Accessibility Inspector
|
|
|
|
# Automated testing (future)
|
|
# - pa11y
|
|
# - axe-core
|
|
```
|
|
|
|
**Manual Testing:**
|
|
|
|
1. **Keyboard Navigation:** Navigate entire application using only keyboard
|
|
2. **Screen Reader:** Test with NVDA (Windows) or VoiceOver (Mac)
|
|
3. **Zoom:** Test at 200% zoom level
|
|
4. **Color Blindness:** Use browser extensions to simulate
|
|
|
|
**Checklist:**
|
|
|
|
- [ ] All images have alt text (or alt="" for decorative)
|
|
- [ ] All form inputs have labels
|
|
- [ ] All interactive elements are keyboard accessible
|
|
- [ ] Color contrast meets WCAG AA (4.5:1 for normal text)
|
|
- [ ] Focus indicators are visible
|
|
- [ ] Headings follow logical hierarchy (h1, h2, h3...)
|
|
- [ ] Error messages are announced to screen readers
|
|
- [ ] Skip links are available
|
|
- [ ] Tables have proper structure (th, scope, caption)
|
|
- [ ] ARIA labels used for icon-only buttons
|
|
|
|
### 8.11 DaisyUI Accessibility
|
|
|
|
DaisyUI components are designed with accessibility in mind, but ensure:
|
|
|
|
```heex
|
|
<!-- Modal accessibility -->
|
|
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
|
|
<div class="modal-box">
|
|
<h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
|
|
<p><%= gettext("Are you sure?") %></p>
|
|
<div class="modal-action">
|
|
<button class="btn" onclick="document.getElementById('my-modal').close()">
|
|
<%= gettext("Cancel") %>
|
|
</button>
|
|
<button class="btn btn-error" phx-click="confirm-delete">
|
|
<%= gettext("Delete") %>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
```
|
|
|
|
---
|
|
|
|
## Conclusion
|
|
|
|
These guidelines are a living document and should evolve with our project and team. When in doubt, prioritize:
|
|
|
|
1. **Clarity over cleverness** - Write code that's easy to understand
|
|
2. **Consistency over perfection** - Follow established patterns
|
|
3. **Testing over hoping** - Write tests for confidence
|
|
4. **Documentation over memory** - Don't rely on tribal knowledge
|
|
5. **Communication over assumption** - Ask questions, discuss trade-offs
|
|
6. **Accessibility for all** - Build inclusive applications
|
|
|
|
Happy coding! 🚀
|
|
|