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.
- Quick Start
- Prerequisites
- Getting Started
- Architecture Overview
- Domain-Driven Design
- Testing
- Development Workflow
- Legacy Integration
- Key Concepts
- Documentation
# 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:4000Or run locally:
# Install dependencies and start
mix setup
cd apps/equity_forge_web && mix phx.serverThis project uses mise for managing tool versions. The required versions are defined in mise.toml:
- Elixir: 1.19
- Erlang: 28
- PostgreSQL: (for development database)
Follow the mise installation guide, then:
mise installThis will automatically install the correct Elixir and Erlang versions.
You can run EquityForge either with Docker (recommended for consistency) or locally (for faster iteration).
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 dbcd /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 phoenixThis 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)
# 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# Clone and start the CivicTechExchange database
git clone https://github.com/DemocracyLab/CivicTechExchange.git
cd CivicTechExchange
docker compose up -d dbDefault connection settings (already configured in config/dev.exs):
config :equity_forge, EquityForge.Repo,
username: "postgres",
password: "change_me_asap",
hostname: "localhost",
database: "postgres"mix setupThis command runs in all child apps and performs:
mix deps.get- Install Elixir dependenciesmix ecto.setup- Create and migrate databasemix assets.setup- Install frontend build tools (Tailwind, esbuild)mix assets.build- Build frontend assets
cd apps/equity_forge_web
mix phx.serverOr from the umbrella root:
cd apps/equity_forge_web && mix phx.serverVisit localhost:4000 in your browser.
- LiveDashboard:
localhost:4000/dev/dashboard - Email Preview:
localhost:4000/dev/mailbox
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.exsordocker-compose.yml(for Docker), or stop the Django application while developing EquityForge.
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 dependenciesChanges to .ex, .exs, and .heex files are automatically detected and reloaded.
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 .envThen update docker-compose.yml to use the .env file:
services:
phoenix:
env_file:
- .envDatabase connection issues:
- Ensure CivicTechExchange's PostgreSQL is running:
docker ps | grep postgres - Check that
host.docker.internalis accessible from the container - On Linux, you may need to use
172.17.0.1instead ofhost.docker.internal
Dependency issues:
# Clear and rebuild everything
docker compose down -v
docker compose build --no-cache
docker compose upAsset compilation issues:
# Rebuild assets inside the container
docker compose exec phoenix mix assets.setup
docker compose exec phoenix mix assets.buildThis 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.
EquityForge follows Domain-Driven Design principles with Phoenix Contexts as bounded contexts.
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- One context per domain - Identity, Messaging, Projects, etc.
- Contexts are the public API - All external code calls context functions
- Schemas are internal - Direct schema access should be rare
- Cross-context communication - Always through public context APIs
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 APIidentity/legacy/user.ex- User schema (Django-compatible)identity/legacy/session.ex- Session schema (Django-compatible)identity/user_token.ex- Phoenix token managementidentity/scope.ex- Caller scope (authorization primitive)
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}EquityForge uses a comprehensive testing strategy covering unit and integration 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_viewapps/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
Unit Tests (apps/equity_forge/test/):
- Test individual modules in isolation
- Focus on business logic, validations, and transformations
- Example:
password_test.exstests Django password hash verification
Integration Tests (apps/equity_forge_web/test/):
- Test full user workflows end-to-end
- Use
Phoenix.LiveViewTestfor LiveView interactions - Example:
login_test.exstests complete login flow
# 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# 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# 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
endFixtures 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")Always run before committing:
mix precommitThis runs:
mix compile --warnings-as-errors- Ensure no warningsmix deps.unlock --unused- Clean up unused dependenciesmix format- Format codemix test- Run test suite
# 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# 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# Install asset build tools
mix assets.setup
# Build assets for development
mix assets.build
# Build assets for production (minified)
mix assets.deployEquityForge maintains schema-level compatibility with the Django application during migration.
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 |
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
endPhoenix 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)The Legacy pattern enables:
- Shared database - Both apps use the same PostgreSQL database
- Gradual migration - New features in Phoenix, existing features remain in Django
- Session sharing - Users logged in to Django can access Phoenix (and vice versa)
- Zero downtime - No "big bang" rewrite
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
endWhen 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
endCurrent Legacy domains:
- Identity - Wraps
auth_useranddjango_sessiontables - Projects (planned) - Will wrap
civictechprojects_projectand related tables
When implementing a new Legacy domain, you must create a migration that defines the table schema using create_if_not_exists. This ensures:
- Development works - Fresh Phoenix-only databases have the tables
- Tests pass - Test database can create tables independently
- 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
endKey points:
- Use
create_if_not_existsinstead ofcreate- 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.
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
endKey LiveView concepts:
- mount/3 - Initialize socket state
- handle_event/3 - Handle user interactions
- Templates -
.heexfiles with~Hsigil - Streams - Efficient collection rendering
EquityForge uses magic link authentication (passwordless):
- User enters email on
/log-in - System sends email with magic link token
- User clicks link → redirected to
/users/log-in/:token - Token validated → user logged in
Traditional password auth is also supported for Django compatibility.
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
endImportant: current_scope (not current_user) is assigned by the auth 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.
- Phoenix Framework: https://hexdocs.pm/phoenix/overview.html
- Phoenix LiveView: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html
- Ecto: https://hexdocs.pm/ecto/Ecto.html
- Elixir: https://hexdocs.pm/elixir/
- Phoenix Guides - Start here
- LiveView Overview
- LiveView Form Bindings
- LiveView Streams
- Testing LiveViews
- Phoenix Contexts - Domain boundaries
- Ecto Schemas
- Ecto Changesets - Data validation
- Ecto Queries
mix phx.gen.auth- The generator used for this project's auth system- Plug - HTTP middleware
Generate local docs:
mix docsOpen doc/index.html in your browser.
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)
- Follow the Elixir Style Guide
- Run
mix formatbefore committing - Keep functions small and focused
- Write descriptive module and function documentation
- Write your code
- Write/update tests
- Run
mix precommit - Commit with descriptive message
- Push and create PR
# 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 filesRemember to:
- Review generated code for DDD compliance
- Add to router if web-accessible
- Write comprehensive tests
- Update this README if needed
[Add your license here]