# Contentgen Backend Deep Dive

This document describes the HTTP contentgen backend runtime in this repository: what it exposes, how a request moves through it, how it chooses products, how generated content is cached, and how it is intended to fit into a Bloomreach Engagement scenario.

The backend is intentionally small. It does not create Bloomreach campaigns, scenarios, email templates, segmentations, or other campaign assets. It only serves the runtime content-generation webhook and, when configured with a write-capable catalog client, writes generated content JSON into a Bloomreach `content` catalog.

## Backend Entrypoint

Start the backend with:

```bash
npm run serve
```

This runs `node ./src/server.js`, which starts the HTTP contentgen webhook backend.

## High-Level Workflow

1. Bloomreach enters a customer into an email scenario.
2. A per-customer webhook action calls the contentgen API.
3. The backend validates the webhook payload.
4. The backend computes a deterministic cache key for the segment, location, strategy, and category.
5. The backend checks whether generated content already exists in the configured content catalog.
6. If content exists, the backend returns `available: true`.
7. If content does not exist, the backend queues background generation and immediately returns `available: false`.
8. The background job selects a product, builds an image prompt, generates or falls back to an image, builds the email/content JSON, and writes it to the catalog.
9. Bloomreach stores the returned `webhook.cache_key` into `customer.customer_variant`.
10. Bloomreach waits or retries until the generated item exists.
11. The dynamic email template reads `catalogs.content.item_by_id(customer.customer_variant).content_json | from_json`.

## HTTP Server

The HTTP server is created by `createContentgenHttpServer()` in `src/server.js`.

Default bind settings:

```text
host: 127.0.0.1
port: 8787
```

Override with:

```bash
CONTENTGEN_HOST=0.0.0.0
CONTENTGEN_PORT=8787
npm run serve
```

### Endpoints

| Method | Path | Behavior |
| --- | --- | --- |
| `GET` | `/health` | Returns `{ "ok": true }`. |
| `GET` | `/contentgen/assets/<file>` | Serves generated image assets from `GENERATED_ASSET_DIR` or `.contentgen-assets`. |
| `POST` | `/contentgen/api/v1/generate` | Main content-generation webhook endpoint. |
| Any other route | Any other path | Returns `404` with `{ "error": "not_found" }`. |

### Request Body Handling

The server:

- Reads the request body as UTF-8.
- Allows an empty body and treats it as `{}`.
- Parses JSON.
- Enforces a default 1 MB body limit.
- Returns `400` for invalid JSON.
- Returns `413` when the body is too large.
- Returns `500` for unhandled server errors.

The server currently does not check the inbound `Authorization` header. The Bloomreach scenario contract includes an authorization header, but auth must be enforced by an upstream proxy, deployment layer, or a future server change.

### Asset Serving

Generated images from the Gemini image adapter are written to `GENERATED_ASSET_DIR` or `.contentgen-assets`.

The asset endpoint uses `path.basename()` on the requested file name, so path traversal segments are stripped before lookup. Supported MIME mappings are:

| Extension | MIME type |
| --- | --- |
| `.jpg`, `.jpeg` | `image/jpeg` |
| `.png` | `image/png` |
| `.webp` | `image/webp` |

Other extensions are served as `application/octet-stream`.

## Webhook Request Contract

The request is validated in `src/contentgen-service.js`.

Required fields:

| Field | Meaning |
| --- | --- |
| `customer_segment_id` | Segment id used in cache key and output metadata. |
| `customer_segment_name` | Human-readable segment name. |
| `customer_segment_description` | Segment explanation used in generated copy. |
| `campaign_strategy_variant` | Strategy variant used in cache key, product scoring, and output metadata. |
| `campaign_strategy_desc` | Strategy description used in generated copy. |
| `campaign_strategy_logic` | Strategy logic used in generated copy and email conditions. |
| `country` | Customer or event country. |
| `city` | Customer or event city. |
| `gender` | Customer gender or segment gender. |

Optional fields:

| Field | Default/Behavior |
| --- | --- |
| `campaign_id` | Accepted but not used in the cache key. |
| `category` | Defaults to `products`; can also be resolved from customer context or product fields. |
| `language` | Copied into output customer context when present. |
| `cta_text` | Defaults to `Show me the deals >`. |
| `cta_url` | Defaults to an empty string unless the service is configured with another default. |
| `cta_button_image_url` | Used as a reference image for image generation when present. |
| `headline` | Overrides generated headline. |
| `subject_line` | Overrides generated email subject. |
| `preheader` | Overrides generated email preheader. |
| `product` | If provided, used directly as the selected product. |
| `customer_context` | Optional object merged with country, city, gender, and category. |

Example local request:

```json
{
  "customer_segment_id": "6a1af1fd34938dd26990a99a",
  "customer_segment_name": "1ST-LUX-CARTIER-LPD-V5K-R390-R390-540",
  "customer_segment_description": "High-value one-time buyer with lifetime spend >= EUR1,000 whose single order included Cartier.",
  "campaign_strategy_variant": "promote more high-value items",
  "campaign_strategy_desc": "VIP win-back using Cartier/high-value treatment for > EUR5,000.",
  "campaign_strategy_logic": "one-time buyer + lifetime revenue >= EUR1,000 + brand=Cartier + lapsed",
  "country": "UK",
  "city": "Leeds",
  "gender": "female",
  "category": "sneakers",
  "cta_url": "https://example.test/deals"
}
```

## Cache Key

The backend computes `cache_key` and `item_id` as the same value.

The key source is:

```text
customer_segment_id | city | country | campaign_strategy_variant | category | brand
```

The brand suffix is included only when it can be resolved from an explicit payload field, customer context, product data, or campaign/segment text such as `brand=Cartier`.

Each part is normalized by:

- Converting to string.
- Trimming whitespace.
- Lowercasing.
- Collapsing internal whitespace.

The normalized source string is hashed with MD5.

Example source:

```text
6a1af1fd34938dd26990a99a|leeds|uk|promote more high-value items|sneakers|cartier
```

The cache key intentionally does not include `campaign_id`, `language`, `gender`, CTA fields, subject overrides, preheader overrides, or the full customer context. If those fields change while the cache-key inputs stay the same, the backend treats the request as the same content item.

## Generate Endpoint Behavior

The main service function is `handleGenerate()`.

### Validation Failure

If the request does not satisfy the schema, the backend returns:

```json
{
  "error": "invalid_request",
  "issues": [
    {
      "path": "customer_segment_id",
      "message": "Invalid input: expected string, received undefined"
    }
  ]
}
```

HTTP status is `400`.

### Cache Hit

If the catalog client returns an existing generated item for the computed key:

```json
{
  "available": true,
  "cache_key": "<md5-item-id>",
  "item_id": "<md5-item-id>",
  "catalog": "content",
  "status": "cached"
}
```

HTTP status is `200`.

### Cache Miss

If no generated item exists:

```json
{
  "available": false,
  "cache_key": "<md5-item-id>",
  "item_id": "<md5-item-id>",
  "catalog": "content",
  "status": "queued"
}
```

HTTP status is `200`.

The background generation job is queued in memory. By default this uses `setImmediate()`. There is no durable queue, retry queue, dead-letter queue, or persisted job state.

### Duplicate Miss

If a second request arrives for the same key while generation is already running, the service returns another `available: false` response but does not queue a second job. Active generation keys are tracked in an in-memory `Set`.

### Catalog Read Failure

If the catalog read fails, the service logs a warning and treats the request as a miss. This keeps a transient upstream read issue from blocking generation.

### Generation Failure

If product lookup, image generation, content build, or catalog write fails inside the background job:

- The service logs `contentgen generation failed for <itemId>: <message>`.
- The in-flight key is removed.
- The original webhook response has already returned `available: false`.
- The next request for the same key can queue generation again.

## Background Generation

Background generation is handled by `generateAndStore()`.

It performs these steps:

1. Select a product through the configured product provider.
2. Build an image prompt.
3. Generate an image or fall back to an existing product image.
4. Build the generated content JSON.
5. Write a catalog record through the configured catalog client.

## Product Selection

Product selection is done by either `StaticProductProvider` or `LoomiMcpProductProvider`.

### Product Selection Order

1. If the webhook payload contains a `product` object, use it directly.
2. Otherwise, if Loomi MCP is configured, query product and/or product variant catalogs.
3. Otherwise, use products from `PRODUCT_CATALOG_FILE`.
4. If no product is available, create a fallback product from the requested category.

### Loomi MCP Query

When Loomi MCP is configured, the backend builds a query from:

```text
category + campaign_strategy_variant + gender
```

It calls `list_catalog_items` against:

- `PRODUCT_CATALOG_ID`, when configured.
- `PRODUCT_VARIANTS_CATALOG_ID`, when configured.

The results are merged, normalized, scored, and sorted.

If the MCP query fails, the provider silently falls back to the static provider so the webhook can continue.

### Scoring

The scorer builds searchable text from these product fields:

- `title`
- `name`
- `brand`
- `category`
- `category_path`
- `category_level_1`
- `category_level_2`
- `category_level_3`
- `description`

It then scores:

| Signal | Score |
| --- | ---: |
| Product text contains requested category | `+20` |
| Product text contains a strategy token longer than 3 characters | `+2` per token |
| Product has an image URL | `+3` |
| Product has a product URL/link | `+2` |

Strategy tokens come from:

- `campaign_strategy_variant`
- `campaign_strategy_desc`
- `campaign_strategy_logic`

Category is the strongest signal. Image and link availability act as tie-breakers.

City and country are not used for product scoring. Gender is used in the Loomi MCP search query but not in the final local score.

### Product Normalization

Selected products are normalized so downstream content can rely on common fields:

| Normalized field | Source fields |
| --- | --- |
| `id` | `id`, `_id`, `product_id`, `sku`, `item_id` |
| `title` | `title`, `name`, `product_name`, fallback to `id` |
| `brand` | `brand` |
| `category` | `category`, `category_path`, `category_level_1` |
| `image_url` | `image_url`, `image`, `product_image_url`, `thumbnail_url` |
| `url` | `url`, `product_url`, `link` |

## Image Generation

The backend has three image generator paths.

### Fallback Image Generator

Used when no image API is configured.

It returns:

- `product.lifestyle_image_url`, if present.
- Else `product.image_url`, if present.
- Else `product.image`, if present.
- Else an empty image URL.

The image status is `not_configured`.

### Google Gemini Image Generator

Used when `GEMINI_API_KEY` or `NANO_BANANA_API_KEY` is configured.

Despite accepting `NANO_BANANA_API_KEY`, this path calls the Google Generative Language API model configured by:

```bash
GEMINI_IMAGE_MODEL=gemini-3-pro-image
GEMINI_IMAGE_SIZE=2K
```

Default model:

```text
gemini-3-pro-image
```

The generator:

1. Builds a text prompt.
2. Attempts to fetch the selected product image and `cta_button_image_url` as inline reference images.
3. Calls the Gemini image model with native `1:1` image format configuration.
4. Extracts inline image data from the response.
5. Writes the model output directly to `GENERATED_ASSET_DIR` or `.contentgen-assets`.
6. Returns an image URL based on `GENERATED_ASSET_BASE_URL`, `CONTENTGEN_PUBLIC_URL`, or `/contentgen/assets`.

### Nano Banana URL Generator

Used only when no Gemini key is configured and `NANO_BANANA_API_URL` is set.

It sends:

- The generated prompt.
- Size `1200x1200`.
- Input images from product image and CTA button image.
- Metadata with city, country, gender, and category.

It expects an image URL in the response.

### Image Prompt Content

The prompt asks for:

- A `1200x1200` promotional lifestyle image.
- A person wearing the selected product.
- Casual styling and accessories.
- A subtle hint of the customer's city and country.
- Weather and styling appropriate to the country in June.
- Poppins headline text.
- A bottom CTA button no wider than 250 px.
- Center-aligned composition.

The default headline format is:

```text
Best <singular-category> deals <gender>
```

Example:

```text
Best sneaker deals female
```

## Generated Content JSON

The generated item is built by `buildGeneratedContent()`.

Top-level structure:

```json
{
  "segment_id": "segment-id",
  "customer_segment_name": "segment name",
  "campaign_strategy_variant_id": "strategy variant",
  "content_hook": "brand:Cartier / sneaker",
  "lifecycle": "LPD",
  "value_band": "lux",
  "market_persona": "uk-female",
  "authorized_incentive": {
    "default": false,
    "max": false
  },
  "marketing": {},
  "contentgen_metadata": {},
  "variants": [],
  "generation": {}
}
```

This mirrors the `marketing-copy.json` item structure. `contentgen_metadata`, `variants`, and `generation` are operational extras used by the backend and existing email bindings.

### Marketing

```json
{
  "hero_products_type": "brand",
  "hero_products_title": "Cartier",
  "subject": "Sneakers picks for your Leeds comeback",
  "hero_image_input": "Create a promotional lifestyle image...",
  "header_image_url": "https://example.test/contentgen/assets/item.png",
  "hero_image_url": "https://example.test/contentgen/assets/item.png",
  "hero_headline": "Best sneaker deals female",
  "hero_cta_button": "Show me the deals >",
  "campaign_headline": "Sneakers picks for Leeds",
  "campaign_paragraph": "promote more high-value items: ...",
  "recommendations_headline": "Picked for Leeds",
  "recommendations_reasoning": "No exact Cartier product was available, so this cascaded to the closest sneakers match.",
  "incentive": false,
  "recommended_products": [],
  "recommended_products_source": {}
}
```

The generated header image is available in both `marketing.header_image_url` and `marketing.hero_image_url`.

### Metadata

```json
{
  "item_id": "<item-id>",
  "generated_at": "2026-06-02T12:00:00.000Z",
  "source_hash": "<item-id>",
  "catalog": "content",
  "customer_context": {
    "country": "UK",
    "city": "Leeds",
    "gender": "female",
    "category": "sneakers",
    "language": "en"
  },
  "strategy": {
    "variant": "strategy variant",
    "description": "strategy description",
    "logic": "strategy logic"
  },
  "product": {},
  "image_status": "generated"
}
```

Defaults:

- Subject: `<Category Title> picks for your <city> comeback`
- Preheader: shortened `campaign_strategy_desc`
- Campaign paragraph: shortened strategy and segment context

### Variants

The current implementation creates one variant:

```json
[
  {
    "subject_line": "Sneakers picks for your Leeds comeback",
    "preheader": "VIP win-back using Cartier/high-value treatment for > EUR5,000.",
    "key_topic": {
      "header_image_url": "https://example.test/contentgen/assets/item.png",
      "overlay_message_html": "<h1 style=\"font-family:Poppins,Arial,sans-serif;\">Best sneaker deals female</h1>",
      "conditions": "one-time buyer + lifetime revenue >= EUR1,000 + brand=Cartier + lapsed"
    },
    "messages": [
      {
        "background_image_url": "https://example.test/contentgen/assets/item.png",
        "headline": "Best sneaker deals female",
        "description": "promote more high-value items: ...",
        "cta_url": "https://example.test/deals",
        "cta_button_text": "Show me the deals >",
        "use_recommendations": true,
        "recommendations": {
          "headline": "Picked for Leeds",
          "items": []
        }
      }
    ]
  }
]
```

### Generation Metadata

```json
{
  "status": "complete",
  "created_at": "2026-06-02T12:00:00.000Z",
  "source_hash": "<item-id>",
  "catalog": "content"
}
```

## Catalog Record Shape

Catalog writes use `buildGeneratedCatalogRecord()`.

The generated item is stored in the record's `content_json` property as a string:

```json
{
  "content_json": "{\"segment_id\":\"segment-id\",\"marketing\":{...}}"
}
```

Bloomreach templates then parse this with:

```jinja
catalogs.content.item_by_id(customer.customer_variant).content_json | from_json
```

## Catalog Clients

The runtime builds adapters from environment variables in `createRuntimeAdaptersFromEnv()`.

### Memory Catalog Client

Default when no Bloomreach write adapter is configured.

Behavior:

- Stores records in a process-local `Map`.
- Works for local development and tests.
- Loses all generated content when the process restarts.

### Loomi MCP Catalog Reader

Configured when:

```bash
LOOMI_MCP_URL=<mcp-http-url>
LOOMI_PROJECT_ID=<project-id>
```

Optional bearer token:

```bash
LOOMI_MCP_BEARER_TOKEN=<token>
```

Fallback bearer token env name:

```bash
N8N_MCP_BEARER_TOKEN=<token>
```

The reader calls MCP tools:

- `get_catalog_item`
- `list_catalog_items`

It can read existing generated content and product catalogs, but it is not the write client.

### Bloomreach Data API Catalog Client

Configured when:

```bash
BLOOMREACH_BASE_URL=<bloomreach-api-base-url>
BLOOMREACH_PROJECT_TOKEN=<project-token>
```

or when `LOOMI_PROJECT_ID` is available as the project token fallback.

Auth options:

```bash
BLOOMREACH_AUTH_HEADER=<full-authorization-header>
BLOOMREACH_API_TOKEN=<token>
BLOOMREACH_API_KEY_ID=<key-id>
BLOOMREACH_API_SECRET=<secret>
```

Write behavior:

- Sends a `POST` to `/data/v2/projects/<project-token>/catalogs/<catalog>/items/<item-id>/partial-update`.
- Wraps data as `{ "properties": ..., "upsert": true }`.
- Normalizes Bloomreach app URLs to API URLs by replacing `.app.` with `.api.` where applicable.

### Bloomreach CLI Catalog Client

Configured with:

```bash
CONTENTGEN_CATALOG_CLIENT=bloomreach-cli
BLOOMREACH_CLI_PATH=../bloomreach-cli/bin/bloomreach.js
BLOOMREACH_CONFIG=../bloomreach-cli/.bloomreachrc.json
BLOOMREACH_PROFILE=<profile>
BLOOMREACH_PROJECT_TOKEN=<project-token>
```

It runs the sibling Bloomreach CLI through `execFile`.

Read behavior:

```bash
bloomreach item <catalogName> <itemId> --output json
```

Write behavior:

```bash
bloomreach catalog partial-update <catalogName> <itemId> --data <record-json> --output json
```

## Read/Write Catalog Composition

The service uses `ReadWriteCatalogClient`.

This allows separate read and write paths:

- Read existing generated content through Loomi MCP.
- Write generated content through Bloomreach Data API or CLI.

If the read client fails and the write client is different, it tries the write client as a fallback read source.

## Environment Variables

### HTTP Server

| Variable | Default | Meaning |
| --- | --- | --- |
| `CONTENTGEN_HOST` | `127.0.0.1` | HTTP bind host. |
| `CONTENTGEN_PORT` | `8787` | HTTP bind port. |
| `GENERATED_ASSET_DIR` | `.contentgen-assets` | Local directory for generated image files. |

### Catalog Identity

| Variable | Default | Meaning |
| --- | --- | --- |
| `CONTENTGEN_CATALOG_NAME` | `content` | Logical catalog name returned in webhook responses and CLI adapter default. |
| `CONTENTGEN_CATALOG_ID` | `CONTENTGEN_CATALOG_NAME` | Catalog id used for Loomi MCP and Bloomreach Data API reads/writes. |

### Loomi MCP

| Variable | Default | Meaning |
| --- | --- | --- |
| `LOOMI_MCP_URL` | none | MCP HTTP endpoint. |
| `LOOMI_MCP_BEARER_TOKEN` | none | Bearer token for MCP requests. |
| `N8N_MCP_BEARER_TOKEN` | none | Fallback bearer token name. |
| `LOOMI_MCP_TIMEOUT_MS` | `30000` | MCP request timeout. |
| `LOOMI_PROJECT_ID` | none | Project id passed to MCP tools and fallback project token for Data API. |
| `PRODUCT_CATALOG_ID` | none | Product catalog id for MCP product lookup. |
| `PRODUCT_VARIANTS_CATALOG_ID` | none | Product variants catalog id for MCP product lookup. |

### Local Product Fallback

| Variable | Default | Meaning |
| --- | --- | --- |
| `PRODUCT_CATALOG_FILE` | none | Local JSON file used by `StaticProductProvider`. |

The file can be either an array or an object containing one of:

- `items`
- `data`
- `products`

### Bloomreach Data API

| Variable | Default | Meaning |
| --- | --- | --- |
| `BLOOMREACH_BASE_URL` | none | Bloomreach base URL. App URLs are normalized to API URLs. |
| `BLOOMREACH_PROJECT_TOKEN` | `LOOMI_PROJECT_ID` fallback | Project token for Data API path. |
| `BLOOMREACH_AUTH_HEADER` | none | Full Authorization header value. |
| `BLOOMREACH_API_TOKEN` | none | Bearer token alternative. |
| `BLOOMREACH_API_KEY_ID` | none | Basic auth key id alternative. |
| `BLOOMREACH_API_SECRET` | none | Basic auth secret alternative. |
| `BLOOMREACH_TIMEOUT_MS` | `30000` | Data API request timeout. |

### Bloomreach CLI

| Variable | Default | Meaning |
| --- | --- | --- |
| `CONTENTGEN_CATALOG_CLIENT` | none | Set to `bloomreach-cli` to use CLI write client. |
| `BLOOMREACH_CLI_PATH` | `../bloomreach-cli/bin/bloomreach.js` | CLI executable or JS entrypoint. |
| `BLOOMREACH_CONFIG` | none | CLI config path. |
| `BLOOMREACH_PROFILE` | none | CLI profile. |
| `BLOOMREACH_PROJECT_TOKEN` | none | Project token passed to CLI. |
| `BLOOMREACH_CLI_TIMEOUT_MS` | `30000` | CLI command timeout. |

### Image Generation

| Variable | Default | Meaning |
| --- | --- | --- |
| `GEMINI_API_KEY` | none | Enables Gemini image generation. |
| `NANO_BANANA_API_KEY` | none | Also enables Gemini image generation unless only `NANO_BANANA_API_URL` is set. |
| `GEMINI_IMAGE_MODEL` | `gemini-3-pro-image` | Gemini model name. |
| `GEMINI_API_VERSION` | `v1beta` | Gemini API version for image generation. |
| `GEMINI_IMAGE_SIZE` | `2K` | Gemini native image size request. |
| `NANO_BANANA_TIMEOUT_MS` | `120000` for Gemini, `60000` for URL adapter | Image generation timeout. |
| `NANO_BANANA_API_URL` | none | Enables URL-based Nano Banana adapter when no Gemini key exists. |
| `CONTENTGEN_PUBLIC_URL` | none | Public contentgen base URL used to build asset URLs. |
| `GENERATED_ASSET_BASE_URL` | derived | Explicit public asset base URL. |

Asset base URL precedence:

1. `GENERATED_ASSET_BASE_URL`
2. `${CONTENTGEN_PUBLIC_URL}/assets`
3. `/contentgen/assets`

## Bloomreach Scenario Context

The backend is designed to be called by a Bloomreach runtime pattern rather than creating Bloomreach assets directly.

### Webhook Action

The scenario should call:

```text
POST https://cod-beta.solution-production.eu/contentgen/api/v1/generate
```

With:

```text
Content-Type: application/json
Authorization: Bearer {{ project_variable.contentgen_api_token }}
```

The generated copiedData uses a public Authorization header value, but the token source is a Bloomreach project variable. Tokens should not be hardcoded in docs, copiedData, or templates.

### Bloomreach Payload Template

The generated payload template passes:

- `scenario.id ~ action.id` as `campaign_id`
- Segment fields from the triggering event
- Strategy fields from the triggering event
- Country, city, and gender from customer properties with event fallback
- Category from `event.category` with `event.product_type` fallback
- CTA and message fields from webhook parametrized parts
- Language, price affinity, product type, and brand in `customer_context`

### Expected Webhook Response

Bloomreach uses:

```text
webhook.available
webhook.cache_key
```

The response catalog is:

```text
content
```

The customer property storing the selected item id is:

```text
customer.customer_variant
```

### Availability Check

Generated Jinja:

```jinja
{% if webhook.available == True %}
  True
{% else %}
  False
{% endif %}
```

### Cache Exists Check

Generated Jinja:

```jinja
{{ snippet('<contentgen_cache_exists_snippet_id>', {"cache_key": webhook.cache_key}) }}
```

The placeholder snippet id must be replaced in Bloomreach with the actual project snippet that checks whether the catalog item exists.

### Customer Properties Set Before Email

The generated scenario sets:

| Property | Value |
| --- | --- |
| `language` | `{{ event.language }}` |
| `email` | `{{ event.email }}` |
| `customer_variant` | `{{ webhook.cache_key }}` |

### Email Template Bootstrap

Generated Jinja:

```jinja
{%- set data = catalogs.content.item_by_id(customer.customer_variant).content_json | from_json -%}
{% if data == None %}{% abort %}{%- endif -%}
```

This aborts rendering if the generated catalog item is missing.

### Email Bindings

The email template reads:

| Email field | Jinja |
| --- | --- |
| Subject line | `{{- data.marketing.subject -}}` |
| Preheader | `{{- data.marketing.campaign_paragraph | default('', true) -}}` |
| Header image | `{{ data.marketing.header_image_url | default(data.marketing.hero_image_url, true) }}` |
| Overlay HTML | `<h1 style="font-family:Poppins,Arial,sans-serif;">{{ data.marketing.hero_headline }}</h1>` |
| Conditions | `{{- data.contentgen_metadata.strategy.logic | default('', true) -}}` |
| Message image | `{{ m.background_image_url }}` |
| Message headline | `{{ m.headline }}` |
| Message description | `{{ m.description }}` |
| CTA URL | `{{ m.cta_url }}` |
| CTA button text | `{{ m.cta_button_text }}` |

Messages are rendered by looping over:

```jinja
{% for m in data.variants[0].messages %}
```

Recommendations are guarded by:

```jinja
{% if m.use_recommendations == True %}
```

## Local Development

Start the server:

```bash
npm run serve
```

Health check:

```bash
curl http://127.0.0.1:8787/health
```

Generate request:

```bash
curl -X POST http://127.0.0.1:8787/contentgen/api/v1/generate \
  -H 'Content-Type: application/json' \
  -d '{
    "customer_segment_id": "segment-1",
    "customer_segment_name": "Lapsed VIP customers",
    "customer_segment_description": "High value customers who have not purchased recently.",
    "campaign_strategy_variant": "win back with premium picks",
    "campaign_strategy_desc": "Use a premium offer to bring lapsed customers back.",
    "campaign_strategy_logic": "lapsed + high value + category affinity",
    "country": "UK",
    "city": "Leeds",
    "gender": "female",
    "category": "sneakers"
  }'
```

With default in-memory storage, a second request for the same key should return cached only after the background job has completed. Generated data is not preserved across process restarts.

## Testing

Run:

```bash
npm test
```

Current tests cover:

- Deterministic contentgen item ids.
- Request validation and default category/CTA behavior.
- Cache miss, queued generation, duplicate in-flight handling, and later cache hit.
- Catalog read failures treated as misses.
- HTTP health, validation, and generate behavior.
- Gemini inline image output saved as served asset.
- Bloomreach Data API URL normalization and partial-update payload.
- Loomi MCP product provider catalog reads.

## Operational Notes And Limitations

- The backend is cache-first and asynchronous. A miss returns before content exists.
- There is no durable queue. Restarting the process loses in-flight jobs.
- In-memory catalog mode loses generated content on restart.
- The cache key excludes several fields that affect output, including gender, language, CTA text, CTA URL, subject override, preheader override, and `campaign_id`.
- The HTTP server does not enforce request authentication.
- Generated image assets are local files unless deployed with persistent shared storage or external asset hosting.
- Catalog read failures are tolerated and treated as misses.
- Catalog write failures happen after the webhook response and require logs or later retries to detect.
- Product selection is simple scoring, not a recommendation engine.
- The backend does not apply consent, suppression, frequency, or recent-conversion checks. Those belong in the Bloomreach scenario before the webhook/email path.
- The backend writes generated catalog content only when explicitly started and configured with a write-capable catalog client.
- Do not run the backend against a live Bloomreach project without confirming catalog target, credentials, and write intent.

## Source Map

| Concern | File |
| --- | --- |
| HTTP routes and server startup | `src/server.js` |
| Request validation, cache handling, generation orchestration | `src/contentgen-service.js` |
| Cache key construction | `src/contentgen-key.js` |
| Catalog, product, MCP, Bloomreach, and image adapters | `src/contentgen-adapters.js` |
| Bloomreach webhook/template contract | `src/contentgen-contract.js` |
| Backend and adapter tests | `test/contentgen-service.test.js` |
