# CLAUDE.md

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

## Build & Development

```bash
npm run build        # Compile TypeScript → dist/
npm run watch        # Compile with --watch for dev
npm run clean        # Remove dist/
npx tsc --noEmit     # Type-check without emitting
```

No test framework or linter is configured. CI is GitLab (`.gitlab-ci.yml`): lint_and_test → build → tag → publish_npm (manual).

Published as `@reachy/audience-module` on npm (v1.0.19). Peer dependency: `@supabase/supabase-js ^2.x`.

## Architecture

Standalone npm package that evaluates audience segmentation criteria against contacts and events. Consumed by `reachy-api` and other Reachy services.

### Execution flow

```
AudienceModule (facade)
  → CriteriaParser.parse()        — normalizes V1/V2/conditions into groups format
  → CriteriaParser.getAudienceType() — 'static' or 'live'
  │
  ├─ Static: StaticAudienceExecutor → SupabaseContactRepository → V2AudienceEngine
  └─ Live:   MemberRepository.listMembers() (injected)
```

**V2AudienceEngine** (~2200 lines) is the core. It evaluates groups of rules:
- **Property rules** (`kind='property'`) — query `contacts` table via Supabase
- **Event rules** (`kind='event'`) — routed to `ClickHouseEventQueryExecutor` when available, falls back to paginated Supabase + JS aggregation

### Criteria formats

Three formats supported (CriteriaParser normalizes all to V2 groups via `coerceCriteriaToGroups`):
- `groups[]` — V2 preferred. Rules have `kind`, `eventName`, `field`, `op`, `frequency`, `attributes`, `time`, `negate`, `interest`, `filters[]`
- `filters[]` — V1 legacy. Special: `field='event'` becomes event rule; `field='custom_field'` uses `customFieldKey`
- `conditions[]` — normalized legacy

Live audience types: `live-actions`, `live-page-visit`, `live-referrer`, `live-page-count`. Everything else is `static`.

### Database backends

- **Supabase (PostgreSQL)**: contacts, properties (JSONB), project settings. Always required.
- **ClickHouse** (optional): event queries. Replaces N paginated Supabase queries with single aggregation queries. Enabled via `clickhouseClient` in `AudienceBuilderConfig`.

### Key tables

- `contacts` — `id`, `reachy_id`, `email`, `properties` (JSONB for custom fields), `is_subscribed`, `created_at`, `updated_at`
- `contact_events` — `contact_id`, `reachy_id`, `event_name`, `event_data` (JSON), `event_timestamp`, keyed by `organization_id + project_id + event_name`. Also has denormalized columns: `current_url`, `domain`, `path`, `referrer`, `page_title`, `utm_*`, `session_id`, `session_is_new`
- `projects` — `settings.timezone` (IANA timezone string)

## V2AudienceEngine internals

### Identity resolution (3-tier fallback)

Event rows are resolved to contact UUIDs in this order:
1. `row.reachy_id` → lookup in `reachyIdToContactId` map
2. `row.contact_id` → validate against `allContactIds` set
3. Email from `event_data` (checks: `email`, `user_email`, `userEmail`, `contact_email`, `contactEmail`) → lookup in `emailToContactId`

Identity maps are lazy-loaded: only when criteria has event rules or negate rules.

### Property rules — JSONB patterns

Known columns (`email`, `first_name`, `last_name`, `phone`, `is_subscribed`, `created_at`, `updated_at`, `name`) query directly. Unknown fields use `properties->>{field}`.

Three special handling paths for JSONB fields:
- **Numeric JSONB** (`shouldNumericJsonb`): When operator is `>`, `>=`, `<`, `<=`, `between` — fetches `id, properties`, then compares in JS (Supabase JSONB numeric comparison is unreliable)
- **Negation JSONB** (`shouldManualJsonbNegation`): `not_equals`/`not_contains` on JSONB — fetches all, filters in JS (handles null semantics correctly)
- **is_not_empty on JSONB**: Uses `filter(propertyObj, 'cs', '{"keyName":')` to check key existence in JSON object

### Event rules — evaluation paths

ClickHouse executor has 4 optimized paths:
1. **First Time**: `GROUP BY contact_id HAVING min(event_timestamp) >= window_start`
2. **Last Time**: `GROUP BY contact_id HAVING max(event_timestamp) <= window_end`
3. **Pure Frequency**: `GROUP BY contact_id` with `count()`/`sum()`/`avg()` + `HAVING`
4. **Fetch & Process**: Fetch rows, apply in-memory filters (time_of_day, day_of_week, day_of_month, event_property, interest)

Supabase fallback: paginated `fetchAll()` + JS aggregation. First/last time uses chunked N+1 pattern (500 IDs per chunk, separate queries for `contact_id` and `reachy_id`).

### Live presets

- `live-page-visit`: Filters on `path`/`current_url` ILIKE + `domain` ILIKE
- `live-referrer`: `session_is_new=true` + referrer/UTM ILIKE
- `live-page-count`: In-memory count threshold

### Interest feature

- **predominantly**: Contact included if matching value count >= any other single value count
- **at_least X%**: Contact included if matching_count / total >= threshold

### Group evaluation strategy

- **OR groups**: All rules run in parallel (`Promise.all`), results unioned
- **AND/NOT groups**: Sequential with early exit on empty set
- **Between groups**: If all combine with OR, run in parallel; otherwise sequential with early exit
- Single-contact evaluation (`matchesContact`) also has early exits: AND+false→false, OR+true→true

## Performance safeguards

| Context | Max rows | Location |
|---------|----------|----------|
| Supabase contacts fetch | 500,000 | V2AudienceEngine `fetchAll` default |
| Supabase event queries | 200,000 | V2AudienceEngine `MAX_EVENT_ROWS` |
| ClickHouse fetch queries | 200,000 | ClickHouseEventQueryExecutor `MAX_FETCH_ROWS` |
| first/last_time boundary | 50,000 per chunk | V2AudienceEngine offset guard |

- **Timezone cache**: Static `_tzCache` with 5-min TTL, keyed by `orgId:projectId`
- **Lazy contact loading**: Identity maps only loaded when needed (event rules or negate)
- **Minimal SELECT**: Event queries select only needed fields (`contact_id, reachy_id` base; add `event_data`/`event_timestamp` only when required)
- **Set operations optimized**: `intersect` iterates smaller set; no intermediate array allocations

## Security

- **ClickHouse**: All user values via `query_params` (`{param:Type}` syntax). Field expressions validated with `^[a-zA-Z0-9_]+$` allowlist.
- **Supabase**: Uses PostgREST query builder (parameterized by default).

## RFM Engine

`RfmEngine` computes Recency-Frequency scores (1-5 each) using percentile cuts (20th/40th/60th/80th). 10 segments: Champions, Loyal Users, Potential Loyalists, New Users, Promising, Needing Attention, About to Sleep, Cannot Lose Them, At Risk, Hibernating.

`RfmSegmentBuilder` converts RFM thresholds into executable audience criteria (groups format with event rules + frequency filters).

## Field mapping reference

`mapAudienceFieldToContactField`: `name`→`first_name`. All other known fields map to themselves. Unknown fields fall through to `properties->>{field}`.

Date normalization (`normalizeDateBoundary`): Accepts `YYYY-MM-DD`, `DD/MM/YYYY`, ISO timestamps. Start boundary → `00:00:00Z`. End boundary → start of next day (exclusive `.lt()`).
