# Brief for an AI coding agent: connect an advertiser / agency campaign tool to POLITRAN

You are implementing a **connector from a campaign management system (agency / advertiser side) to the POLITRAN REST API v1**, so that political-advertising notices are declared automatically instead of being re-typed in the POLITRAN web form.

Read `politran-api-ai-instructions.md` (same folder) first — it is the API contract. This brief tells you **what to build and how to structure it**.

## 1. Goal and scope

- For every placement of a political campaign at a Czech publisher, create a **notice** in POLITRAN, upload its creatives, send it to the publisher and keep the local record in sync with the publisher's decision.
- One POLITRAN notice = **one campaign × one publisher** (cost, reach and distribution period are per publisher). A campaign running at three publishers means three notices.
- Out of scope: creating publishers or organisations, anything on the publisher side, the public page.

## 2. Prerequisites you must ask the user for

1. Base URL: `https://politran.cz/api/v1` (there is no sandbox; test with the SPIR test organisation and the SPIR test publisher, see `politran-api-ai-instructions.md` §12).
2. An **organisation token** (side `advertiser`, scopes `notices:read notices:write creatives:read creatives:write webhooks:manage`, created by the organisation's main user in POLITRAN → Settings → API tokens). Store it in a secret store / env var `POLITRAN_API_TOKEN`. Never commit it.
3. A public HTTPS URL where the connector can receive webhooks (optional but recommended; polling is the fallback).

## 3. Data model on your side

Add to the campaign/placement entity:

| Field | Purpose |
|---|---|
| `politran_notice_id` (int) | primary link |
| `politran_short_code` (string) | human identifier, appears on the public page and in e-mails |
| `politran_status` (string) | mirror of `status` |
| `politran_public_url` | for reporting |
| `politran_updated_at` | value of `updated_at` from the last read — for `If-Match` and change detection |
| `politran_last_error` (json) | last `error` envelope, shown to the user |
| `politran_idempotency_key` | stable key per placement, e.g. `"<system>-placement-<id>-v<revision>"` |

## 4. Field mapping

Load `GET /notice-fields` at runtime and build the payload from it. Typical mapping:

| POLITRAN field | Source in a campaign tool |
|---|---|
| `publisher_org_id` | resolve once per publisher via `GET /publishers` (match by `ico`, fallback by name); store the id |
| `campaign_start_date`, `campaign_end_date` | campaign flight |
| `distribution_start_date`, `distribution_end_date` | placement flight at this publisher (if different) |
| `campaign_cost_amount` + `campaign_cost_currency` | booked media cost at this publisher (`"1234.50"`, `"CZK"`) |
| `political_campaign_cost_amount` | total political campaign budget, if known |
| `funding_finance`, `funding_territory`, `funding_source`, `pricing_methodology` | from the client's compliance data |
| `controlling_entity…` / `payer…` | use `controlling_same_as_advertiser: true` and `payer_source: "advertiser"` unless the client says otherwise |
| `election_relation_type` + `election_link` / `official_election_info_url` or legislative fields | campaign brief |
| `is_personalised` + `targeting_*` | targeting settings of the placement; when `true`, the targeting fields become mandatory |
| `complaint_mechanism` | client's complaint URL |
| `medium`, `political_ad_reach`, `views_count`, `engagements_count` | placement format and delivered reach (update after the flight) |

`advertiser_*` fields are filled by POLITRAN from the organisation — do not send them on create.

## 5. Behaviour to implement

1. **Startup check:** `GET /me`; abort with a clear message if `data.side != "advertiser"` or a required scope is missing.
2. **Create:** `POST /notices` with `Idempotency-Key` and, if all required fields are filled, `"send": true`. On `422` store `error.details` on the placement and surface the missing fields to the user (labels come from `GET /notice-fields`). Never retry a `422` without changing the payload.
3. **Edit:** while `data.editable` is true, `PATCH /notices/{id}` with only changed keys and `If-Match`. On `412` re-read, merge, retry once. On `409 INVALID_STATE` do not modify locally — show the current status.
4. **Creatives:** upload each creative once (`POST /notices/{id}/creatives`, multipart, ≤ 10 MB, ≤ 150 per notice); keep the returned creative id; delete/replace when the placement's creative changes and the notice is still editable.
5. **Send:** `POST /notices/{id}/send-to-publisher` when the user approves. Handle `422` as in step 2.
6. **Sync back:** register a webhook (`POST /webhooks`, events `["*"]`) and verify signatures; additionally poll `GET /notices?updated_since=<last sync>&sort=updated_at` every 15 minutes as a safety net. Update `politran_status`, `public_url`, and show `messages` (publisher notes) to the user. On `notice.returned_for_rework` create a task for the account manager with `data.message`.
7. **Rate limits:** honour `429` with `Retry-After`; batch work with a small concurrency (≤ 2 parallel requests).
8. **Logging:** log `X-Request-Id`, HTTP status and `error.code` for every call; make `request_id` visible in the UI error message.

## 6. Acceptance checklist

- [ ] Creating the same placement twice (same idempotency key) produces one notice.
- [ ] A placement missing a mandatory field shows the exact field labels from `notice-fields`, and nothing is created when `send: true` was used.
- [ ] Editing after the publisher returned the notice works; editing while `SENT_TO_PUBLISHER` is blocked with a friendly message.
- [ ] Webhook receiver rejects a tampered body and an old timestamp; duplicate deliveries are ignored.
- [ ] Token revocation (`401`) and access revocation (`403 API_ACCESS_NOT_APPROVED`) stop the sync with a clear alert instead of an error loop.
- [ ] No secrets in the repository; token comes from configuration.

## 7. Suggested structure

```
politran/
  Client            # HTTP wrapper: auth header, Accept, request id, error envelope → typed exception, retry on 429/5xx
  FieldCatalog      # cache of GET /notice-fields (TTL 1 h) + payload builder + required-field validation
  PublisherResolver # cache of GET /publishers
  NoticeSync        # create / patch / creatives / send / status mirror
  WebhookController # signature check, dedup, enqueue
  PollJob           # updated_since backfill
```

Start with `Client` + `GET /me`, then one end-to-end happy path with the SPIR test organisation before wiring the UI.
