Aller au contenu

Story TENANT-005: Agency backend — entity, CRUD, default PRINCIPAL agency & AgencyDisabled

Module: tenant-admin Slice: S9 (Vertical Slice Decomposition §11, backend) + S3a delta (default agency) Side: [BACKEND] Version target: [V0.0.2] Priority: 5 Depends on: TENANT-004 Can develop concurrently with: TENANT-007 (BYO), TENANT-008 (lifecycle) Merge order: After TENANT-004, before TENANT-006 Estimated complexity: M PRD User Stories: TENANT-US-08 (+ AC-02.1 from TENANT-US-02) Wireframe: N/A — backend only (UI is TENANT-006)


Objective

Introduce the agency entity: the organizational sub-unit layer within a tenant (agencies are sub-units, NOT separate tenants). This is the V0.0.2 milestone (multi-agency). It adds the agency table + tenant-scoped CRUD endpoints, turns the provisioning DEFAULT_AGENCY step (a no-op in V0.0.0) into a real "create PRINCIPAL agency" step (satisfying AC-02.1), enforces TR-007 (cannot disable the last active agency), and emits AgencyDisabled. It also introduces the agency_id key that downstream flows (NOTIF queuing, OP routing) bind to from V0.0.2.


Backend Scope

Entities

agency (tenant DB, tenant-scoped): id UUID PK · tenant_id UUID FK · code TEXT (unique within tenant, case-insensitive) · name TEXT · city TEXT · address_line1 TEXT NULL · phone TEXT NULL (E.164) · manager_name TEXT NULL · status TEXT (ACTIVE|INACTIVE) · created_at TIMESTAMPTZ · updated_at TIMESTAMPTZ.

Migrations

  • db/migration/tenant/V2__agency.sql — PK(id); UK(tenant_id, lower(code)); IDX(tenant_id, status); partial IDX (tenant_id) WHERE status='ACTIVE' (cheap last-active count for TR-007). Applies to shared tenant DB now and to any BYO DB during provisioning (V0.0.4).

Service Layer

  • AgencyService (all queries scoped by tenant_id from TenantContext): list(status?), create(AgencyCreate), edit(agencyId, AgencyEdit), disable(agencyId), reactivate(agencyId).
  • TR-007 (cannot disable last active): disable counts ACTIVE agencies for the tenant; if activeCount <= 1 → reject (422) « Au moins une agence active est requise par cabinet. »
  • TR-008 binding: code uniqueness enforced case-insensitively within the tenant; duplicate → 422.
  • Provisioning delta (AC-02.1): update TenantOnboardingService DEFAULT_AGENCY step (previously no-op in TENANT-004) to create exactly one agency code='PRINCIPAL', name=<tenant.legal_name>, city=<tenant.address_city>, status=ACTIVE if none exists (idempotent). Populate defaultAgencyId in the TenantOnboarded event payload from this step forward.
  • AgencyDisabled record in …controlplane.tenant.events: {tenantId, agencyId, disabledAt}. Published AFTER_COMMIT; outbox-persisted. Consumers (OP filter, ING refuse new files, AUTH block new operator assignments) react.
  • Disable is soft (status=INACTIVE); no hard delete in V1/V0. Historical contracts/payments stay tied to the agency.

API Endpoints

Method Path Request Response Auth
GET /api/tenant/me/agencies?status=&cursor=&limit= 200 list tenant-admin / tenant-operator / tenant-viewer
POST /api/tenant/me/agencies {code, name, city, addressLine1?, phone?, managerName?} 201 agency · 422 dup code tenant-admin
PATCH /api/tenant/me/agencies/{agencyId} editable fields 200 · 422 tenant-admin
POST /api/tenant/me/agencies/{agencyId}/disable 200 · 422 last-active tenant-admin
POST /api/tenant/me/agencies/{agencyId}/reactivate 200 tenant-admin

Validation Rules

  • Required: code, name, city. Optional: addressLine1, phone, managerName.
  • code unique within tenant (case-insensitive); allowed chars letters/digits/hyphen.
  • phone E.164 (+225…) when present.

Multi-Tenant Considerations

agency is tenant-scoped — every query MUST include tenant_id (critical-rules #1); REVIEWER blocks otherwise. For a BYO-DB tenant the agency rows live in that tenant's DB; routing resolves via TenantContext past TenantAwareDataSource (no code divergence). /tenant/me/... endpoints verify the caller's tenant_id claim matches the targeted tenant; cross-tenant access → 403 (never 404). Agencies are sub-units within ONE tenant — never their own tenant.

Audit & Logging

  • AGENCY_CREATED, AGENCY_EDITED, AGENCY_DISABLED, AGENCY_REACTIVATED (AUDIT §B.1 / arch §7.3) with actor, tenant_id, agency_id, before/after. Best-effort via outbox (NFR-09).

Frontend Scope

N/A — backend only. UI is TENANT-006.


Acceptance Criteria

AC-02.1: Default-agency auto-creation
Given a tenant transitions PENDING → ACTIVE (from V0.0.2 onward)
Then exactly one agency is created with code='PRINCIPAL', name=<tenant.legal_name>, city=<tenant.address.city>, status=ACTIVE
And the DEFAULT_AGENCY provisioning step is idempotent (re-run does not create a second)
And TenantOnboarded carries the resulting defaultAgencyId
AC-08.1: Required fields + unique code
When I create an agency
Then {code, name, city} are required; {address, phone, manager_name} are optional
And code must be unique within the tenant (case-insensitive); a duplicate is rejected with « Ce code est déjà utilisé par une autre agence du cabinet. »
AC-08.2: Disable is soft
When I disable an agency
Then status=INACTIVE, an AgencyDisabled event is emitted, no new campaigns/ingestion/operator assignments may target it, but historical contracts/payments stay tied to it
And there is no hard delete
AC-08.3: Cannot disable the only active agency
Given the tenant has exactly one ACTIVE agency
When I attempt to disable it
Then the action is refused with « Au moins une agence active est requise par cabinet. »

Compliance Rules

  • No INCERTAIN/PRÉLIMINAIRE obligation raised by agency management. Agencies are an internal organizational layer; country_code (FNE/regulatory) is resolved at the tenant level, not per agency.

Standards & Conventions

  • docs/standards/database-guidelines.md — case-insensitive UK, partial index, undo scripts.
  • docs/standards/java-spring-guidelines.md — Spring Data JDBC, events past-tense in …tenant.events.
  • docs/standards/multi-tenant-model.md — agency = sub-unit within tenant; tenant-scoped.
  • docs/standards/api-guidelines.md/tenant/me/... tenant-context check; 403 not 404; cursor pagination.
  • docs/standards/critical-rules.md — #1 tenant isolation; audit on mutation.

Testing Requirements

Unit Tests

  • TR-007: disable refused when activeCount ≤ 1.
  • Case-insensitive code uniqueness within tenant.

Integration Tests

  • Tenant isolation (arch §13.2.1): agency of tenant A invisible to tenant B; get of A's agency from B → ForbiddenException.
  • Provisioning now creates PRINCIPAL agency; re-run idempotent; defaultAgencyId in event.
  • Disable emits AgencyDisabled; reactivate restores; audit entries written.
  • Both DB profiles: agency CRUD works on shared and BYO-analogue DB.

What QA Will Validate

  • Default PRINCIPAL agency present after activation; last-active rule enforced server-side.

Out of Scope

  • Agency management UI (TENANT-006).
  • Assigning users to agencies / agency_scope on users (owned by AUTH).
  • Bulk CSV import of agencies (V0 is UI-create only; bulk is not in scope).
  • Per-operator assignment within an agency (deferred to V1).

Definition of Done

  • [ ] agency migration applies on shared + BYO-analogue DB; UK(tenant_id, lower(code)); last-active partial index
  • [ ] CRUD endpoints implemented; /tenant/me/... verifies tenant context (403 not 404)
  • [ ] DEFAULT_AGENCY provisioning step creates PRINCIPAL idempotently; defaultAgencyId in TenantOnboarded
  • [ ] TR-007 enforced server-side; AgencyDisabled emitted on disable
  • [ ] AC-02.1, AC-08.1, AC-08.2, AC-08.3 pass
  • [ ] AGENCY_CREATED/EDITED/DISABLED/REACTIVATED audit entries present
  • [ ] Tenant isolation verified (every agency query scoped by tenant_id)
  • [ ] Unit + integration tests passing