# CampaignFlow Client
An Elixir client library for the [Campaign Flow API](https://campaignflow.com.au), built with the [Req](https://hexdocs.pm/req) HTTP client.
## ⚠️ AI Generated! ⚠️
Full disclosure, this repo was almost completely generated by Claude Code. Use at your own risk.
## Features
- **Shared Token Manager** - OAuth2 tokens managed by GenServer processes and shared across your application
- **Multi-Environment Support** - Use both production and test environments simultaneously
- **Automatic Token Refresh** - Tokens refreshed automatically with race-condition-free coordination
- **Simple API** - No need to thread updated client through your code
- **Full API Coverage** - Complete coverage for all Campaign Flow endpoints
- **Type-Safe** - Function signatures with `@spec` for compile-time checking
- **Comprehensive Error Handling** - Structured error types for robust error handling
- **Embedded Mock Server** - Optional Plug-based mock API you can mount in your app for local development and end-to-end tests
## Installation
Add `campaign_flow` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:campaign_flow, "~> 2.2.0"}
]
end
```
## Configuration
The client uses **application-level configuration** where credentials are configured globally and shared across your entire application. Each environment (production, test, etc.) has its own dedicated TokenManager process.
### Basic Configuration
Configure environments in your `config/config.exs`:
```elixir
config :campaign_flow,
environments: [
prod: [
base_url: "https://app.campaignflow.com.au/api/v2",
client_id: System.get_env("CAMPAIGNFLOW_PROD_CLIENT_ID"),
client_secret: System.get_env("CAMPAIGNFLOW_PROD_CLIENT_SECRET")
],
test: [
base_url: "https://test.campaignflow.com.au/api/v2",
client_id: System.get_env("CAMPAIGNFLOW_TEST_CLIENT_ID"),
client_secret: System.get_env("CAMPAIGNFLOW_TEST_CLIENT_SECRET")
]
]
```
### Runtime Configuration
For production deployments, use `config/runtime.exs`:
```elixir
import Config
if config_env() == :prod do
config :campaign_flow,
environments: [
prod: [
base_url: "https://app.campaignflow.com.au/api/v2",
client_id: System.fetch_env!("CAMPAIGNFLOW_PROD_CLIENT_ID"),
client_secret: System.fetch_env!("CAMPAIGNFLOW_PROD_CLIENT_SECRET")
]
]
end
```
### Environment Variables
Set the following environment variables:
```bash
# Production credentials
export CAMPAIGNFLOW_PROD_CLIENT_ID="your_prod_client_id"
export CAMPAIGNFLOW_PROD_CLIENT_SECRET="your_prod_client_secret"
# Test environment credentials (if needed)
export CAMPAIGNFLOW_TEST_CLIENT_ID="your_test_client_id"
export CAMPAIGNFLOW_TEST_CLIENT_SECRET="your_test_client_secret"
```
### Multiple Environments
You can configure and use multiple environments simultaneously:
```elixir
# Configure both prod and test
config :campaign_flow,
environments: [
prod: [
base_url: "https://app.campaignflow.com.au/api/v2",
client_id: System.get_env("CAMPAIGNFLOW_PROD_CLIENT_ID"),
client_secret: System.get_env("CAMPAIGNFLOW_PROD_CLIENT_SECRET")
],
test: [
base_url: "https://test.campaignflow.com.au/api/v2",
client_id: System.get_env("CAMPAIGNFLOW_TEST_CLIENT_ID"),
client_secret: System.get_env("CAMPAIGNFLOW_TEST_CLIENT_SECRET")
]
]
# Use them simultaneously in your application
prod_client = CampaignFlow.Client.new(environment: :prod)
test_client = CampaignFlow.Client.new(environment: :test)
{:ok, prod_campaigns} = CampaignFlow.Client.Campaigns.list(prod_client)
{:ok, test_campaigns} = CampaignFlow.Client.Campaigns.list(test_client)
```
## Usage
### Creating a Client
Clients are lightweight structs that reference a configured environment. Credentials are stored in the application config, not in the client:
```elixir
# Create a client for the production environment
client = CampaignFlow.Client.new(environment: :prod)
# Create a client for the test environment
client = CampaignFlow.Client.new(environment: :test)
# Or use the convenience function for test environment
client = CampaignFlow.Client.test()
# You can also override the base URL if needed
client = CampaignFlow.Client.new(
environment: :prod,
base_url: "https://custom.example.com/api/v2"
)
```
### Campaigns
```elixir
# List campaigns
{:ok, campaigns} = CampaignFlow.Client.Campaigns.list(client)
# Get a specific campaign
{:ok, campaign} = CampaignFlow.Client.Campaigns.get(client, 123)
# Create a new campaign
{:ok, campaign} = CampaignFlow.Client.Campaigns.create(client, %{
name: "Summer Campaign 2024",
agency_id: 1,
property_id: 2
})
# Update a campaign
{:ok, campaign} = CampaignFlow.Client.Campaigns.update(client, 123, %{
name: "Updated Campaign Name"
})
# Add a comment to a campaign
{:ok, response} = CampaignFlow.Client.Campaigns.add_comment(client, 123, %{
comment: "Campaign approved by client"
})
# Set campaign status
{:ok, response} = CampaignFlow.Client.Campaigns.set_status(client, 123, %{
status: "approved"
})
# Send approved email
{:ok, response} = CampaignFlow.Client.Campaigns.send_approved_email(client, 123)
```
### Campaign Vendors
```elixir
# List campaign vendors
{:ok, vendors} = CampaignFlow.Client.Campaigns.list_vendors(client, 123)
# Get a specific vendor
{:ok, vendor} = CampaignFlow.Client.Campaigns.get_vendor(client, 123, 456)
# Add a vendor to a campaign
{:ok, vendor} = CampaignFlow.Client.Campaigns.add_vendor(client, 123, %{
vendor_id: 456
})
# Update a campaign vendor
{:ok, vendor} = CampaignFlow.Client.Campaigns.update_vendor(client, 123, 456, %{
status: "approved"
})
# Remove a vendor from a campaign
{:ok, response} = CampaignFlow.Client.Campaigns.remove_vendor(client, 123, 456)
# Send campaign to vendor
{:ok, response} = CampaignFlow.Client.Campaigns.send_campaign_to_vendor(client, 123, 456)
# Verify vendor contact
{:ok, response} = CampaignFlow.Client.Campaigns.verify_vendor_contact(client, 123, 456)
```
### Agencies
```elixir
# List agencies
{:ok, agencies} = CampaignFlow.Client.Agencies.list(client)
# Get a specific agency
{:ok, agency} = CampaignFlow.Client.Agencies.get(client, 123)
```
### Invoices
```elixir
# List invoices
{:ok, invoices} = CampaignFlow.Client.Invoices.list(client)
# Get a specific invoice
{:ok, invoice} = CampaignFlow.Client.Invoices.get(client, 123)
# Create an invoice
{:ok, invoice} = CampaignFlow.Client.Invoices.create(client, %{
campaign_id: 123,
amount: 1000.00
})
# Update an invoice
{:ok, invoice} = CampaignFlow.Client.Invoices.update(client, 123, %{
amount: 1200.00
})
```
### Campaign Budgets
```elixir
# List campaign budgets
{:ok, budgets} = CampaignFlow.Client.CampaignBudgets.list(client)
# Get a specific budget
{:ok, budget} = CampaignFlow.Client.CampaignBudgets.get(client, 123)
# Create a campaign budget
{:ok, budget} = CampaignFlow.Client.CampaignBudgets.create(client, %{
campaign_id: 123,
amount: 10000.00
})
# Update a campaign budget
{:ok, budget} = CampaignFlow.Client.CampaignBudgets.update(client, 123, %{
amount: 12000.00
})
# List finance options
{:ok, options} = CampaignFlow.Client.CampaignBudgets.list_finance_options(client, 123)
# Get a finance quote
{:ok, quote} = CampaignFlow.Client.CampaignBudgets.get_finance_quote(client, 123, "OPTION_CODE")
```
### Finance Applications
```elixir
# List finance applications
{:ok, applications} = CampaignFlow.Client.FinanceApplications.list(client)
# Get a specific application
{:ok, application} = CampaignFlow.Client.FinanceApplications.get(client, 123)
# Set application status
{:ok, response} = CampaignFlow.Client.FinanceApplications.set_status(client, 123, %{
status: "approved"
})
# Submit an application
{:ok, response} = CampaignFlow.Client.FinanceApplications.submit(client, 123)
```
### Users, Tenants, Properties
```elixir
# Users
{:ok, users} = CampaignFlow.Client.Users.list(client)
{:ok, user} = CampaignFlow.Client.Users.get(client, 123)
# Tenants
{:ok, tenants} = CampaignFlow.Client.Tenants.list(client)
{:ok, tenant} = CampaignFlow.Client.Tenants.get(client, 123)
# Properties
{:ok, properties} = CampaignFlow.Client.Properties.list(client)
{:ok, property} = CampaignFlow.Client.Properties.get(client, 123)
```
### Error Handling
The client returns tuples with `{:ok, result}` or `{:error, reason}`:
```elixir
case CampaignFlow.Client.Campaigns.get(client, 123) do
{:ok, campaign} ->
# Process campaign
IO.inspect(campaign)
{:error, :not_found} ->
# Handle not found
IO.puts("Campaign not found")
{:error, :unauthorized} ->
# Handle authentication error
IO.puts("Authentication failed")
{:error, {:validation_error, details}} ->
# Handle validation errors
IO.inspect(details)
{:error, reason} ->
# Handle other errors
IO.inspect(reason)
end
```
### Pagination
Most list endpoints support pagination:
```elixir
# Get page 2 with 50 items per page
{:ok, campaigns} = CampaignFlow.Client.Campaigns.list(client,
page: 2,
per_page: 50
)
```
## Authentication
The client uses a **shared token manager** architecture for OAuth2 authentication:
1. **Application Startup**: When your application starts, a `TokenManager` GenServer process is started for each configured environment (`:prod`, `:test`, etc.)
2. **Automatic Token Acquisition**: When you make your first API request, the TokenManager automatically obtains an access token using the OAuth2 client credentials flow
3. **Token Caching**: The token is cached in the TokenManager process and shared across all processes in your application
4. **Automatic Refresh**: When a token expires, it's automatically refreshed. Only one refresh occurs even with concurrent requests
5. **Race-Condition Free**: GenServer's message queue ensures only one token refresh happens at a time, preventing duplicate refresh requests
### How It Works
```elixir
# Multiple processes can safely use the same client
client = CampaignFlow.Client.new(environment: :prod)
# These calls all share the same token from TokenManager
Task.async(fn -> CampaignFlow.Client.Campaigns.list(client) end)
Task.async(fn -> CampaignFlow.Client.Agencies.list(client) end)
Task.async(fn -> CampaignFlow.Client.Users.list(client) end)
# When the token expires, only ONE refresh request is made
# All concurrent calls automatically receive the new token
```
### Benefits
- **Shared State**: One token per environment, not one per client instance
- **Thread-Safe**: Race-condition-free token refresh coordination
- **Memory Efficient**: Reduced memory usage compared to per-client tokens
- **Secure**: Credentials never stored in client structs, only in TokenManager processes
- **Simple**: No need to manually manage or refresh tokens
## Available Resources
The following resource modules are available:
- `CampaignFlow.Client.Campaigns` - Campaign management
- `CampaignFlow.Client.Agencies` - Agency operations
- `CampaignFlow.Client.Invoices` - Invoice management
- `CampaignFlow.Client.CampaignBudgets` - Campaign budget operations
- `CampaignFlow.Client.FinanceApplications` - Finance application management
- `CampaignFlow.Client.Users` - User operations
- `CampaignFlow.Client.Tenants` - Tenant management
- `CampaignFlow.Client.Properties` - Property operations
- `CampaignFlow.Client.Referrals` - Referral management
- `CampaignFlow.Client.Signatories` - Signatory operations
## Mock Server (Embedded Mode)
The library ships with an optional embedded mock Campaign Flow server that you can run inside your own application. It's intended for:
- Local development without real API credentials
- Integration and end-to-end tests that exercise the full client → HTTP → server flow without hitting production
- CI environments where you want deterministic, sandboxed referral data
The mock server is implemented as a Plug router backed by your application's existing `Ecto.Repo` (PostgreSQL). It is opt-in and only loads when the optional dependencies are present.
### Optional Dependencies
To use the mock server, add the following optional dependencies to your application's `mix.exs`:
```elixir
def deps do
[
{:campaign_flow, "~> 2.2.0"},
# Required only if you use the embedded mock server:
{:bandit, "~> 1.0"},
{:plug, "~> 1.14"},
{:ecto_sql, "~> 3.10"},
{:postgrex, "~> 0.17"}
]
end
```
If you mount the mock router inside an existing Phoenix app, you likely already have `plug` and `ecto_sql`/`postgrex` and only need to ensure they are available.
### Configuration
Enable the mock server in the environment(s) where you want it to run (typically `config/dev.exs` and `config/test.exs`):
```elixir
# config/test.exs
config :campaign_flow, :mock_server,
enabled: true,
repo: MyApp.Repo
```
When `enabled: true`, the application supervisor automatically starts `CampaignFlow.MockServer` for you. The `:repo` option tells the mock server which `Ecto.Repo` to use for storage — it must be a Postgres repo owned by your application.
Then point a Campaign Flow client environment at the mock URL:
```elixir
config :campaign_flow,
environments: [
mock: [
base_url: "http://localhost:4000/campaign-flow-mock/api/v2",
client_id: "test_key",
client_secret: "test_secret"
]
]
# In your code
client = CampaignFlow.Client.new(environment: :mock)
```
The mock server validates that an `Authorization: Bearer ...` header is present, but it does not verify the token value, so any non-empty `client_id`/`client_secret` will work.
### Migrations
The mock server stores data in normalized PostgreSQL tables (`properties`, `campaigns`, `campaign_budgets`, `agents`, `vendor_parties`, `referrals`). Generate a migration in your application and call into [`CampaignFlow.MockServer.Migrations`](lib/campaign_flow/mock_server/migrations.ex):
```bash
mix ecto.gen.migration add_campaign_flow_mock_server
```
```elixir
defmodule MyApp.Repo.Migrations.AddCampaignFlowMockServer do
use Ecto.Migration
def up, do: CampaignFlow.MockServer.Migrations.up(version: 1)
def down, do: CampaignFlow.MockServer.Migrations.down(version: 1)
end
```
You can pass a `:prefix` option to install the tables in a non-`public` schema if you need to isolate them.
### Mounting in a Phoenix Router
Forward a path in your Phoenix router to [`CampaignFlow.MockServer.Router`](lib/campaign_flow/mock_server/router.ex):
```elixir
# lib/my_app_web/router.ex
forward "/campaign-flow-mock", CampaignFlow.MockServer.Router
```
This makes the mock available at `http://localhost:4000/campaign-flow-mock/api/v2/...` alongside the rest of your Phoenix app — no extra port, no separate process to manage.
### Implemented Endpoints
The mock server currently implements a focused subset of the API for end-to-end referral testing:
| Method | Path | Description |
|--------|----------------------------|---------------------------------------------------|
| POST | `/api/v2/referrals` | Create a referral (atomic, normalized via `Ecto.Multi`) |
| GET | `/api/v2/referrals/:id` | Fetch a previously-created referral |
| GET | `/health` | Health check |
Additional endpoints can be added over time — contributions welcome.
### Resetting State
Between tests you can clear all mock-server data with:
```elixir
CampaignFlow.MockServer.clear_all()
```
If your test suite uses `Ecto.Adapters.SQL.Sandbox`, transactional rollbacks will normally handle cleanup for you and you won't need to call this directly.
## Development
```bash
# Get dependencies
mix deps.get
# Run tests
mix test
# Generate documentation
mix docs
# Format code
mix format
```
## License
MIT
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.