feat(fm): one agreement per partner, edited in place

sequenceDiagram
  participant P as portal / OEG
  participant FM as our FM
  participant DB as federation_agreements
  participant X as Partner FM
  P->>FM: POST /internal/agreements (partner already has one)
  FM-->>P: 409 agreement-exists, "Edit agreement {id} instead."
  P->>FM: PATCH /internal/agreements/{id} {permitted_zone_ids, zone_resources}
  Note over FM: create checks run on the merged agreement<br/>(e.g. zone_resources must name permitted zones → 400)
  FM->>DB: update in place (unique: one non-expired row per partner)
  FM->>X: ZONES REMOVE / ADD for the zones that changed
  P->>FM: PATCH {status: suspended}
  FM->>X: ZONES REMOVE (all offered zones)
  P->>FM: PATCH {status: active}
  FM->>X: ZONES ADD

A partner could hold several agreements, and the newest valid one won. Changing zones or terms meant creating another, which in the portal showed up as a list where the last entry quietly replaced the others. GSMA OPG.04 has no agreement object (zones are offered "based on the prior agreement"), so the shape is ours to pick, and one editable agreement per partner is what the portal needs.

POST /internal/agreements now answers 409 agreement-exists when the partner already has an agreement that isn't expired, and names it. PATCH takes every field except the partner: zones, zone terms, API types and app mappings as well as status and dates. It runs the create checks on the merged result, so a PATCH that leaves zone_resources pointing at a removed zone gets a 400. A PATCH that would bring an expired agreement back next to the live one also gets the 409. Zone edits reach a federated partner as ZONES ADD/REMOVE through the notifier that already exists.

A partial unique index enforces the rule: one row per partner where status <> 'expired'. Migration 0008 resolves partners that already have several. It keeps the agreement FM applies today (active and valid now, else active, else the newest) and marks the rest expired, so the history stays readable.

flowchart LR
  A["operator-3 (Athens)<br/>active, valid until 2027-12-31"] -->|kept| K[active]
  B["operator-3 (Athens)<br/>suspended"] -->|0008| E[expired]

Merge order: OEG !47 relays the new 409 (without it OEG turns it into a 502); it's safe to merge before this. Portal !15 (merged) uses the wider PATCH and should go last, because develop rejects the new fields.

Result
full suite, Postgres + NATS + Keycloak as in CI 442 passed, 1 skipped; same on a fresh database
mypy, ruff clean
migration on the testbed (ns oop, three operators, DBs backed up first) OP-1 had two agreements for operator-3: the active one stays, the suspended one becomes expired; OP-2 and OP-3 unchanged
second agreement for the same partner 409 agreement-exists, "Edit agreement 1a4d2b51… instead."
PATCH removing Barcelona / adding it back 200; OP-1 FM zone_change_notified REMOVE / ADD delivered: true; OP-2 FM applies the callback; OP-2's zone list loses and regains Barcelona
PATCH leaving zone_resources for a zone not permitted 400, zone_resources lists zones missing from permitted_zone_ids
PATCH reviving an expired agreement next to the live one 409 agreement-exists
portal (!15 (merged) branch): edit end date, add and remove a flavour, suspend, activate every PATCH 200; suspend drops Barcelona from OP-2's zones, activate brings it back

op1-agreement-card

op2-zones-without-barcelona

🤖 Generated with Claude Code

Merge request reports

Loading
Loading