# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

The Orchard Web Service — Audience Data Management Platform. Manages ad platform connections (Meta, TikTok, Google), audience sharing, and ad reporting integrations.

## Tech stack

- **Python 3.13**, FastAPI, SQLAlchemy 2.x, Pydantic v2, anydi (DI)
- **Databases**: Snowflake (production), PostgreSQL (tests/local)
- **SQL**: Jinja2 templates via `jinja2sql` in `dmp/*/sql/`
- **Package manager**: `uv`

## Key commands

```bash
make fmt              # ruff fix + format
make lint             # mypy + ruff check + ruff format --check
make test             # pytest tests/unit/
make test_unit        # pytest with coverage (--cov dmp)
make dev              # uvicorn on :8001 with reload

# Run a single test
pytest tests/unit/audiences/handlers/test_get_audiences.py::TestGetAudiencesHandler::test_name -v

# Docker (local dev)
make up               # docker compose up (build + run)
make down             # docker compose down
```

## Architecture

### Request flow

```
Router (dmp/api/{domain}/router.py)
  → Handler (dmp/{domain}/handlers/)
    → Repository/Service (dmp/{domain}/repositories/ or services/)
      → DB (dmp/adapters/db/)  ←  SQL templates (dmp/{domain}/sql/)
```

### Handler pattern

Every feature is a `@singleton` handler class injected via anydi `Inject()`. Requests and responses are frozen dataclasses:

```python
@dataclass(frozen=True)
class GetThingsRequest:
    identity_id: str
    filters: MyFilters

@singleton
class GetThingsHandler:
    permission = Permission("things", "view")

    def __init__(self, db: DefaultDB, auth_service: AuthService, ...) -> None:
        ...

    def handle(self, request: GetThingsRequest) -> list[ThingDTO]:
        self.auth_service.authorize_for_permission(request.identity_id, self.permission)
        ...
```

Handlers live in `dmp/{domain}/handlers/` and are imported through the domain's `__init__.py`.

### Dependency injection (anydi)

- `dmp/container.py` — registers all singletons in `AppModule`
- `@singleton` decorator marks a class as DI-managed; dependencies are resolved from `__init__` type hints
- Request-scoped DB sessions are provided via `default_db_session_factory` / `reporting_db_session_factory`
- In routes: `handler: Annotated[MyHandler, Inject()]`
- In tests: use `TestModule` in `tests/unit/module.py` to override providers

### Database access

- `DefaultDB` — PostgreSQL (local/tests), used for writes and transactional queries
- `ReportingDB` — Snowflake (production), used for read-heavy analytics queries
- `@transactional` decorator on methods auto-manages transactions (requires `self.db` or `self.reporting_db`)
- `db.rollback_transaction()` used in tests to reset state after each test

### SQL templates

SQL lives in `dmp/{domain}/sql/` as Jinja2 `.sql` files. Macros can be shared via `_common.sql` imports:

```sql
{% from 'audience/_common.sql' import my_macro %}
SELECT * FROM my_table
WHERE 1=1
{{ my_macro(criteria) }}
LIMIT {{ limit }} OFFSET {{ offset }}
```

Available Jinja2 filters: `|orderby`, `|escape_like`, `|enum_values`, `|enum_choices`, `|array_values`

Template search paths are registered in `Settings.jinja2sql_template_searchpath`.

**Dialect differences**: Snowflake uses `CONTAINS(COLLATE(col, 'en-ci-ai'), ...)` for case-insensitive search; PostgreSQL uses `ILIKE`.

### Authentication & authorization

- JWT validated by `JWTAuthenticationMiddleware` (disabled locally via `AUTH_ALLOW_FULL_ACCESS=true`)
- Identity extracted in `dmp/api/auth.py` → `get_identity()`; routes declare `identity_id: auth.IdentityId`
- Handlers call `auth_service.authorize_for_permission(identity_id, Permission("resource", "action"))` — raises on unauthorized
- `AuthService` uses `PdpAuthorizationBackend` in prod, `AccessAuthorizationBackendStub` locally

### Pagination

Paginated endpoints use `LimitOffsetPage[T]` as `response_model`. Filter/pagination params are a Pydantic model with `Query()` annotation:

```python
@router.get("/things", response_model=LimitOffsetPage[schemas.Thing])
def get_things(
    identity_id: auth.IdentityId,
    filters: Annotated[schemas.ThingFilters, Query()],
    handler: Annotated[GetThingsHandler, Inject()],
) -> Any:
    ...
```

## Testing conventions

Tests live in `tests/unit/{domain}/` mirroring the source structure.

Key fixtures (from `tests/unit/conftest.py`):
- `db` — PostgreSQL session; tests marked `@pytest.mark.db` auto-rollback
- `create_model(Model, **kwargs)` / `build_model(Model, **kwargs)` — factory helpers (polyfactory)
- `enable_features(["flag_name"])` — context manager for feature flags
- `override_settings(**kwargs)` — context manager for Settings overrides
- Mock fixtures: `auth_service_mock`, `s3_client_mock`, etc. — override DI singletons via `container.override()`
