# Brief for an AI coding agent: connect a publisher's internal system to POLITRAN

You are implementing a **connector between a publisher's ad-operations / CRM system and the POLITRAN REST API v1**, so that notices sent by advertisers appear in the internal system, can be matched to booked campaigns and approved (published), returned or rejected without switching applications.

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. Scope

- Import every notice the publisher receives (level 1), keep it in sync, and push decisions back: `publish`, `return-for-rework` (with a message), `reject`.
- Level 2: attach creatives, update publisher-side fields (dates, cost, reach, distribution), bulk exports.
- Level 3: webhooks, change history, notes.
- Out of scope: creating notices on behalf of advertisers (not available), the public page.

## 2. Prerequisites to ask the user for

1. Base URL: `https://politran.cz/api/v1` (there is no sandbox; test with the SPIR test organisation, see `politran-api-ai-instructions.md` §12).
2. An **organisation token** (side `publisher`; scopes `notices:read notices:write creatives:read creatives:write exports webhooks:manage`) created by the organisation's main user. Store as `POLITRAN_API_TOKEN`.
3. Confirmation that the publisher organisation has the "URL for reporting incorrect information" filled in POLITRAN (otherwise `publish` fails with `409 PUBLISHER_REPORT_URL_MISSING`).
4. A public HTTPS webhook URL (recommended).

## 3. Data model on your side

Table `politran_notices` (or fields on the campaign record):

| Field | Purpose |
|---|---|
| `notice_id`, `short_code` | keys |
| `status`, `updated_at` | mirror; `updated_at` also for `If-Match` |
| `advertiser_org_id`, `advertiser_name`, `advertiser_ico` | matching to the client / order |
| `campaign_start_date`, `campaign_end_date`, `public_url` | display |
| `internal_campaign_id` | link to the booked campaign (manual or rule-based match by `advertiser_ico` + dates) |
| `last_error` | last `error` envelope |

## 4. Behaviour to implement

1. **Startup check:** `GET /me` → `data.side == "publisher"`, scopes present.
2. **Initial import:** page through `GET /publisher/notices?sort=updated_at&per_page=100` and store everything; then keep `last_sync = max(updated_at)`.
3. **Incremental sync:** webhook events (`notice.sent_to_publisher`, `notice.resubmitted`, `notice.updated`, `notice.returned_for_rework`, `notice.creative_added`, `notice.message_added`, …) **and** a 15-minute poll `GET /publisher/notices?updated_since=<last_sync>`. Always fetch the detail (`GET /publisher/notices/{id}`) after an event — the payload is a summary.
4. **Matching:** propose the internal campaign by `advertiser_ico` and overlapping dates; let the operator confirm. Show `messages` (advertiser notes) and creatives (`download_url`, streamed with the Bearer token — never expose the token to the browser; proxy the download).
5. **Decisions:** buttons map to `POST …/publish`, `POST …/return-for-rework` (`message` required — make it a mandatory text field), `POST …/reject`. Only show a button if `allowed_actions` contains it. Handle `409 INVALID_STATE` by re-reading the notice. Handle `409 PUBLISHER_REPORT_URL_MISSING` with an instruction to fill the URL in POLITRAN settings.
6. **Publisher edits:** `PATCH /publisher/notices/{id}` with only the editable fields (dates, `campaign_cost_amount`, `political_ad_reach`, `distribution_*`, `views_count`, `engagements_count`, `medium`, `is_suspended` + `suspension_history`). Never send advertiser/controlling/payer fields or `publisher_org_id` (they return `422`). Use `If-Match`.
7. **Creatives:** `POST /publisher/notices/{id}/creatives` (multipart) only while `creatives_write` is allowed.
8. **Exports:** monthly report = `POST /publisher/exports` (`csv` or `xlsx`, `campaign_from/to`), poll `GET /publisher/exports/{id}`, download `tabular_url`. Use `Idempotency-Key` = `"report-YYYY-MM"`.
9. **Webhook receiver:** verify `X-Politran-Signature` (HMAC-SHA256 over `t + "." + rawBody`, 5-minute tolerance), respond `2xx` immediately, process asynchronously, deduplicate by event `id`.
10. **Operations:** log `X-Request-Id`; alert on `401`, `403 API_ACCESS_NOT_APPROVED`, repeated `429`, and when `GET /publisher/webhooks/{id}` reports `is_active: false` (re-enable with `PATCH is_active: true`, then backfill by `updated_since`).

## 5. Acceptance checklist

- [ ] A newly sent notice appears in the internal system within 1 minute (webhook) and within 15 minutes without webhooks.
- [ ] Publish / return / reject from the internal UI change the status in POLITRAN and the advertiser receives POLITRAN's e-mail (verify with the SPIR test advertiser).
- [ ] Return-for-rework without a message is impossible in the UI.
- [ ] A user without the right to decide sees the notice but no action buttons (enforce in your system; the API token is organisation-wide).
- [ ] Tampered or replayed webhook requests are rejected; duplicates are ignored.
- [ ] Creative downloads never leak the API token to end users.

## 6. Suggested structure

```
politran/
  Client            # auth, error envelope → exceptions, 429 backoff, request-id logging
  NoticeImporter    # initial + incremental sync (updated_since), detail fetch, mapping to internal model
  DecisionService   # publish / return / reject / patch with allowed_actions + If-Match
  CreativeProxy     # authenticated streaming of creatives to internal users
  ExportService     # request → poll → download
  WebhookController # signature, dedup, queue
```

Build `Client` + import first, verify with SPIR's test advertiser organisation, then add decisions and webhooks.
