Skip to content

DemocracyLab/EquityForge

Repository files navigation

EquityForge

EquityForge is a modern Phoenix/LiveView application built as a "new head" on top of the legacy DemocracyLab/CivicTechExchange Django application. This umbrella project provides a phased migration path while maintaining compatibility with the existing Django database schema.

Table of Contents

Quick Start

# 1. Start the legacy PostgreSQL database
cd /path/to/CivicTechExchange
docker compose up -d db

# 2. Start EquityForge with Docker
cd /path/to/equity_forge_umbrella
docker compose up

# 3. Visit http://localhost:4000

Or run locally:

# Install dependencies and start
mix setup
cd apps/equity_forge_web && mix phx.server

Prerequisites

This project uses mise for managing tool versions. The required versions are defined in mise.toml:

  • Elixir: 1.19
  • Erlang: 28
  • PostgreSQL: (for development database)

Install mise

Follow the mise installation guide, then:

mise install

This will automatically install the correct Elixir and Erlang versions.

Getting Started

You can run EquityForge either with Docker (recommended for consistency) or locally (for faster iteration).

Option A: Docker Development (Recommended)

1. Set up the PostgreSQL database

EquityForge is configured to connect to the PostgreSQL database created by the CivicTechExchange Django project.

# Clone the CivicTechExchange repository (if you haven't already)
git clone https://github.com/DemocracyLab/CivicTechExchange.git
cd CivicTechExchange

# Start the PostgreSQL database using Docker Compose
docker compose up -d db

2. Start EquityForge with Docker Compose

cd /path/to/equity_forge_umbrella

# Build and start the Phoenix container
docker compose up

# Or run in detached mode
docker compose up -d

# View logs
docker compose logs -f phoenix

This will:

  • Build the development Docker image
  • Install all dependencies
  • Run database migrations
  • Start the Phoenix server on localhost:4000
  • Enable hot-reloading (code changes are immediately reflected)

3. Useful Docker commands

# Stop the containers
docker compose down

# Rebuild the image (after dependency changes)
docker compose build

# Run mix commands inside the container
docker compose exec phoenix mix test
docker compose exec phoenix mix ecto.migrate
docker compose exec phoenix iex -S mix

# Access the container shell
docker compose exec phoenix sh

Option B: Local Development

1. Set up the PostgreSQL database

# Clone and start the CivicTechExchange database
git clone https://github.com/DemocracyLab/CivicTechExchange.git
cd CivicTechExchange
docker compose up -d db

Default connection settings (already configured in config/dev.exs):

config :equity_forge, EquityForge.Repo,
  username: "postgres",
  password: "change_me_asap",
  hostname: "localhost",
  database: "postgres"

2. Install dependencies

mix setup

This command runs in all child apps and performs:

  • mix deps.get - Install Elixir dependencies
  • mix ecto.setup - Create and migrate database
  • mix assets.setup - Install frontend build tools (Tailwind, esbuild)
  • mix assets.build - Build frontend assets

3. Start the Phoenix server

cd apps/equity_forge_web
mix phx.server

Or from the umbrella root:

cd apps/equity_forge_web && mix phx.server

Visit localhost:4000 in your browser.

Interactive Development Tools

Note: The Django application may be running on the same port (4000). If there's a port conflict, you can change the Phoenix port in config/dev.exs or docker-compose.yml (for Docker), or stop the Django application while developing EquityForge.

Docker Development Details

Hot Reloading

The Docker Compose configuration mounts your local source code as volumes, enabling hot-reloading:

volumes:
  - ./apps:/app/apps          # Application code
  - ./config:/app/config      # Configuration
  - build:/app/_build         # Persisted build artifacts
  - deps:/app/deps            # Persisted dependencies

Changes to .ex, .exs, and .heex files are automatically detected and reloaded.

Environment Variables

You can customize environment variables in docker-compose.yml or create a .env file:

# Copy the example environment file
cp .env.example .env

# Edit as needed
vi .env

Then update docker-compose.yml to use the .env file:

services:
  phoenix:
    env_file:
      - .env

Troubleshooting Docker

Database connection issues:

  • Ensure CivicTechExchange's PostgreSQL is running: docker ps | grep postgres
  • Check that host.docker.internal is accessible from the container
  • On Linux, you may need to use 172.17.0.1 instead of host.docker.internal

Dependency issues:

# Clear and rebuild everything
docker compose down -v
docker compose build --no-cache
docker compose up

Asset compilation issues:

# Rebuild assets inside the container
docker compose exec phoenix mix assets.setup
docker compose exec phoenix mix assets.build

Architecture Overview

Umbrella Structure

This is a Phoenix umbrella application with two apps:

apps/
├── equity_forge/          # Core business logic (contexts, schemas)
│   ├── lib/
│   │   └── equity_forge/
│   │       ├── identity/  # Identity & authentication context
│   │       ├── repo.ex    # Database repository
│   │       └── *.ex       # Other contexts (future domains)
│   └── test/
│
└── equity_forge_web/      # Web interface (LiveViews, controllers)
    ├── lib/
    │   └── equity_forge_web/
    │       ├── components/     # Reusable UI components
    │       ├── controllers/    # Phoenix controllers
    │       ├── live/          # LiveView modules
    │       ├── router.ex      # Route definitions
    │       └── user_auth.ex   # Auth plugs & session management
    ├── assets/                # Frontend assets
    │   ├── css/
    │   ├── js/
    │   └── vendor/
    └── test/

Key principle: Business logic lives in equity_forge, presentation lives in equity_forge_web.

Domain-Driven Design

EquityForge follows Domain-Driven Design principles with Phoenix Contexts as bounded contexts.

Context Organization

Each domain is organized as a Phoenix Context under apps/equity_forge/lib/equity_forge/:

# apps/equity_forge/lib/equity_forge/identity.ex
defmodule EquityForge.Identity do
  @moduledoc """
  The Identity context.
  
  Provides user authentication and session management with Django compatibility.
  """
  
  # Public API functions
  def register_user(attrs), do: ...
  def get_user_by_email(email), do: ...
  
  # Private implementation details
  # ...
end

Context Guidelines

  1. One context per domain - Identity, Messaging, Projects, etc.
  2. Contexts are the public API - All external code calls context functions
  3. Schemas are internal - Direct schema access should be rare
  4. Cross-context communication - Always through public context APIs

Example: Identity Context

The Identity context (apps/equity_forge/lib/equity_forge/identity.ex) demonstrates proper DDD:

# ✅ GOOD: Use context function
EquityForge.Identity.register_user(%{email: "user@example.com"})

# ❌ BAD: Don't directly access schemas
EquityForge.Identity.Legacy.User.changeset(...)

Key files:

  • identity.ex - Public API
  • identity/legacy/user.ex - User schema (Django-compatible)
  • identity/legacy/session.ex - Session schema (Django-compatible)
  • identity/user_token.ex - Phoenix token management
  • identity/scope.ex - Caller scope (authorization primitive)

The Scope Pattern

EquityForge.Identity.Scope represents the calling context (who is making the request):

# Create a scope for a user
scope = Scope.for_user(user)

# Pass scope to context functions for authorization & filtering
MyContext.list_items(scope)

In LiveViews and Controllers, use @current_scope (not @current_user):

# In LiveView
def mount(_params, _session, socket) do
  scope = socket.assigns.current_scope
  items = MyContext.list_items(scope)
  {:ok, assign(socket, items: items)}
end

# In templates
{@current_scope.user.email}

Testing

EquityForge uses a comprehensive testing strategy covering unit and integration tests.

Running Tests

# Run all tests from umbrella root
mix test

# Run tests for a specific app
cd apps/equity_forge && mix test
cd apps/equity_forge_web && mix test

# Run a specific test file
mix test test/equity_forge/identity_test.exs

# Run tests that match a pattern
mix test --only live_view

Test Organization

apps/equity_forge/test/
├── equity_forge/
│   ├── identity_test.exs         # Context integration tests
│   └── identity/
│       └── legacy/
│           ├── password_test.exs  # Unit tests for password hashing
│           └── session_test.exs   # Unit tests for Django sessions
└── support/
    ├── data_case.ex              # Test case for Ecto tests
    └── fixtures/
        └── identity_fixtures.ex  # Test data factories

apps/equity_forge_web/test/
├── equity_forge_web/
│   ├── controllers/
│   │   └── page_controller_test.exs  # Controller integration tests
│   ├── live/
│   │   └── user_live/
│   │       ├── login_test.exs         # LiveView integration tests
│   │       ├── registration_test.exs
│   │       └── settings_test.exs
│   └── user_auth_test.exs             # Auth plug tests
└── support/
    └── conn_case.ex                   # Test case for controller tests

Testing Philosophy

Unit Tests (apps/equity_forge/test/):

  • Test individual modules in isolation
  • Focus on business logic, validations, and transformations
  • Example: password_test.exs tests Django password hash verification

Integration Tests (apps/equity_forge_web/test/):

  • Test full user workflows end-to-end
  • Use Phoenix.LiveViewTest for LiveView interactions
  • Example: login_test.exs tests complete login flow

Writing Tests

Context Tests (Integration)

# apps/equity_forge/test/equity_forge/identity_test.exs
defmodule EquityForge.IdentityTest do
  use EquityForge.DataCase
  
  alias EquityForge.Identity
  
  describe "register_user/1" do
    test "creates user with valid email" do
      assert {:ok, user} = Identity.register_user(%{email: "test@example.com"})
      assert user.email == "test@example.com"
    end
    
    test "returns error with invalid email" do
      assert {:error, changeset} = Identity.register_user(%{email: "invalid"})
      assert "has invalid format" in errors_on(changeset).email
    end
  end
end

LiveView Tests (Integration)

# apps/equity_forge_web/test/equity_forge_web/live/user_live/login_test.exs
defmodule EquityForgeWeb.UserLive.LoginTest do
  use EquityForgeWeb.ConnCase, async: true
  
  import Phoenix.LiveViewTest
  import EquityForge.IdentityFixtures
  
  test "user can log in with magic link", %{conn: conn} do
    user = user_fixture()
    
    # Visit login page
    {:ok, view, _html} = live(conn, ~p"/log-in")
    
    # Submit email
    view
    |> element("#login-form")
    |> render_submit(%{email: user.email})
    
    # Verify magic link email sent
    assert_email_sent()
  end
end

Schema/Module Tests (Unit)

# apps/equity_forge/test/equity_forge/identity/legacy/password_test.exs
defmodule EquityForge.Identity.Legacy.PasswordTest do
  use ExUnit.Case, async: true
  
  alias EquityForge.Identity.Legacy.Password
  
  describe "verify_password/2" do
    test "validates Django PBKDF2 hashed password" do
      hash = "pbkdf2_sha256$870000$..."
      assert Password.verify_password("password123", hash)
    end
  end
end

Test Helpers

Fixtures create test data:

# apps/equity_forge/test/support/fixtures/identity_fixtures.ex
import EquityForge.IdentityFixtures

# Create a confirmed user
user = user_fixture()

# Create an unconfirmed user
unconfirmed = unconfirmed_user_fixture()

# Create a user scope
scope = user_scope_fixture(user)

LazyHTML for testing rendered output:

html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "#login-form")

Development Workflow

Pre-commit Checks

Always run before committing:

mix precommit

This runs:

  1. mix compile --warnings-as-errors - Ensure no warnings
  2. mix deps.unlock --unused - Clean up unused dependencies
  3. mix format - Format code
  4. mix test - Run test suite

Code Quality

# Format code (auto-fix)
mix format

# Check formatting (CI)
mix format --check-formatted

# Run Credo (static analysis)
mix credo

# Check for unused dependencies
mix deps.unlock --unused

Database Operations

# Create database
mix ecto.create

# Run migrations
mix ecto.migrate

# Reset database (drop, create, migrate, seed)
mix ecto.reset

# Rollback last migration
mix ecto.rollback

# Generate a new migration
mix ecto.gen.migration migration_name

Asset Management

# Install asset build tools
mix assets.setup

# Build assets for development
mix assets.build

# Build assets for production (minified)
mix assets.deploy

Legacy Integration

EquityForge maintains schema-level compatibility with the Django application during migration.

Database Schema Compatibility

The Identity context uses Django's existing tables:

Django Table Phoenix Schema Purpose
auth_user Identity.Legacy.User User accounts
django_session Identity.Legacy.Session Session storage

Password Compatibility

Django uses PBKDF2-SHA256 password hashing. EquityForge verifies these hashes using pbkdf2_elixir:

# apps/equity_forge/lib/equity_forge/identity/legacy/password.ex
defmodule EquityForge.Identity.Legacy.Password do
  def verify_password(password, hash) do
    # Handles Django's pbkdf2_sha256$iterations$salt$hash format
  end
end

Session Compatibility

Phoenix can read/write Django sessions for gradual migration:

# Create Django-compatible session
{:ok, session_key} = Identity.generate_legacy_session(user)

# Validate Django session
user = Identity.get_user_by_legacy_session(session_key)

Migration Strategy

The Legacy pattern enables:

  1. Shared database - Both apps use the same PostgreSQL database
  2. Gradual migration - New features in Phoenix, existing features remain in Django
  3. Session sharing - Users logged in to Django can access Phoenix (and vice versa)
  4. Zero downtime - No "big bang" rewrite

The Legacy Pattern for Django Tables

The Legacy pattern is used when wrapping existing Django database tables. This pattern will be reused across multiple domains during the migration period.

When to use the Legacy pattern:

  • The domain has an existing Django table to wrap (e.g., auth_user, projects_project)
  • You need to maintain compatibility with Django's schema and behavior
  • Both applications need to read/write the same data during migration

Example: Wrapping Django's projects table

# ✅ Legacy domain - wrap existing Django table
defmodule EquityForge.Projects.Legacy.Project do
  use Ecto.Schema
  
  # Django table name from CivicTechExchange
  @primary_key {:id, :id, autogenerate: true}
  schema "civictechprojects_project" do
    field :project_name, :string
    field :project_description, :string
    field :project_short_description, :string
    field :project_url, :string
    field :project_slug, :string
    field :project_creator_id, :integer
    field :is_searchable, :boolean
    field :deleted, :boolean
    # Django uses timestamp with time zone fields
    field :project_date_created, :utc_datetime
    field :project_date_modified, :utc_datetime
  end
end

# Public API in the context
defmodule EquityForge.Projects do
  alias EquityForge.Projects.Legacy.Project, as: LegacyProject
  alias EquityForge.Repo
  
  def get_project(id), do: Repo.get(LegacyProject, id)
  
  def list_projects do
    Repo.all(from p in LegacyProject, where: p.deleted == false)
  end
end

When NOT to use the Legacy pattern:

  • You're creating a new domain with no Django equivalent
  • You're adding new tables that only Phoenix will use
# ✅ New domain - standard Phoenix schema
defmodule EquityForge.Analytics.Event do
  use Ecto.Schema
  
  schema "events" do
    field :name, :string
    timestamps()  # Use standard Ecto timestamps
  end
end

Current Legacy domains:

  • Identity - Wraps auth_user and django_session tables
  • Projects (planned) - Will wrap civictechprojects_project and related tables

Creating Migrations for Legacy Tables

When implementing a new Legacy domain, you must create a migration that defines the table schema using create_if_not_exists. This ensures:

  1. Development works - Fresh Phoenix-only databases have the tables
  2. Tests pass - Test database can create tables independently
  3. Production compatibility - Migration safely skips if Django tables already exist

Example migration for wrapping a legacy table:

# apps/equity_forge/priv/repo/migrations/20251212000000_legacy_projects.exs
defmodule EquityForge.Repo.Migrations.LegacyProjects do
  use Ecto.Migration

  @moduledoc """
  Creates Django-compatible project tables for legacy interoperability.

  This migration works in two scenarios:
  1. Fresh install - Creates civictechprojects_project table with Django schema
  2. Existing Django database - Skips table creation if already exists
  """

  def change do
    # Create table only if it doesn't exist (Django may have already created it)
    create_if_not_exists table(:civictechprojects_project) do
      add :project_name, :string, size: 200, null: false
      add :project_description, :string, size: 4000, null: false
      add :project_short_description, :string, size: 140, null: false
      add :project_url, :string, size: 2083, null: false
      add :project_slug, :string, size: 100, null: false
      add :project_creator_id, :integer, null: false
      add :project_location, :string, size: 200, null: false
      add :project_city, :string, size: 100, null: false
      add :project_state, :string, size: 100, null: false
      add :project_country, :string, size: 100, null: false
      add :is_searchable, :boolean, null: false, default: true
      add :is_created, :boolean, null: false, default: false
      add :is_private, :boolean, null: false, default: false
      add :deleted, :boolean, null: false, default: false
      add :project_date_created, :utc_datetime
      add :project_date_modified, :utc_datetime
    end

    create_if_not_exists index(:civictechprojects_project, [:project_creator_id])
    create_if_not_exists index(:civictechprojects_project, [:project_slug])
  end
end

Key points:

  • Use create_if_not_exists instead of create - this prevents errors when Django has already created the table
  • Match Django's exact column types, sizes, and constraints
  • Include appropriate indexes that Django uses
  • Document both scenarios in the migration moduledoc

See apps/equity_forge/priv/repo/migrations/20251201160321_legacy_identity.exs for a complete reference example.

Key Concepts

Phoenix LiveView

EquityForge uses LiveView for real-time, interactive UIs without JavaScript:

# apps/equity_forge_web/lib/equity_forge_web/live/user_live/login.ex
defmodule EquityForgeWeb.UserLive.Login do
  use EquityForgeWeb, :live_view
  
  def mount(_params, _session, socket) do
    {:ok, assign(socket, form: to_form(%{}))}
  end
  
  def handle_event("submit", %{"email" => email}, socket) do
    # Handle form submission
    {:noreply, socket}
  end
end

Key LiveView concepts:

  • mount/3 - Initialize socket state
  • handle_event/3 - Handle user interactions
  • Templates - .heex files with ~H sigil
  • Streams - Efficient collection rendering

Authentication Flow

EquityForge uses magic link authentication (passwordless):

  1. User enters email on /log-in
  2. System sends email with magic link token
  3. User clicks link → redirected to /users/log-in/:token
  4. Token validated → user logged in

Traditional password auth is also supported for Django compatibility.

Router Organization

Routes are organized by authentication requirement:

# Public routes (anyone can access)
scope "/", EquityForgeWeb do
  pipe_through [:browser]
  
  live_session :current_user,
    on_mount: [{EquityForgeWeb.UserAuth, :mount_current_scope}] do
    live "/log-in", UserLive.Login, :new
    live "/sign-up", UserLive.Registration, :new
  end
end

# Protected routes (login required)
scope "/", EquityForgeWeb do
  pipe_through [:browser, :require_authenticated_user]
  
  live_session :require_authenticated_user,
    on_mount: [{EquityForgeWeb.UserAuth, :require_authenticated}] do
    live "/users/settings", UserLive.Settings, :edit
  end
end

Important: current_scope (not current_user) is assigned by the auth system.

Component System

Reusable UI components in apps/equity_forge_web/lib/equity_forge_web/components/:

# Use core components in templates
<.button type="submit">Submit</.button>
<.input field={@form[:email]} type="email" label="Email" />
<.flash kind={:info} flash={@flash} />

Components are defined in core_components.ex using Phoenix.Component.

Documentation

Official Documentation

Recommended Reading

Phoenix & LiveView

Architecture & Patterns

Authentication

  • mix phx.gen.auth - The generator used for this project's auth system
  • Plug - HTTP middleware

Generating Documentation

Generate local docs:

mix docs

Open doc/index.html in your browser.

Module Documentation

Well-documented modules include:

  • EquityForge.Identity - Identity context API (apps/equity_forge/lib/equity_forge/identity.ex:1)
  • EquityForge.Identity.Scope - Caller scope pattern (apps/equity_forge/lib/equity_forge/identity/scope.ex:1)
  • EquityForgeWeb.UserAuth - Auth plugs & helpers (apps/equity_forge_web/lib/equity_forge_web/user_auth.ex:1)

Contributing

Code Style

  • Follow the Elixir Style Guide
  • Run mix format before committing
  • Keep functions small and focused
  • Write descriptive module and function documentation

Commit Workflow

  1. Write your code
  2. Write/update tests
  3. Run mix precommit
  4. Commit with descriptive message
  5. Push and create PR

Adding a New Context

# Generate a new context (example: Projects)
mix phx.gen.context Projects Project projects name:string description:text

# This creates:
# - apps/equity_forge/lib/equity_forge/projects.ex (context)
# - apps/equity_forge/lib/equity_forge/projects/project.ex (schema)
# - Migration file
# - Test files

Remember to:

  1. Review generated code for DDD compliance
  2. Add to router if web-accessible
  3. Write comprehensive tests
  4. Update this README if needed

License

[Add your license here]

Support

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published