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 bytenant_idfromTenantContext):list(status?),create(AgencyCreate),edit(agencyId, AgencyEdit),disable(agencyId),reactivate(agencyId).- TR-007 (cannot disable last active):
disablecounts ACTIVE agencies for the tenant; ifactiveCount <= 1→ reject (422) « Au moins une agence active est requise par cabinet. » - TR-008 binding:
codeuniqueness enforced case-insensitively within the tenant; duplicate → 422. - Provisioning delta (AC-02.1): update
TenantOnboardingServiceDEFAULT_AGENCY step (previously no-op in TENANT-004) to create exactly one agencycode='PRINCIPAL',name=<tenant.legal_name>,city=<tenant.address_city>,status=ACTIVEif none exists (idempotent). PopulatedefaultAgencyIdin theTenantOnboardedevent payload from this step forward. AgencyDisabledrecord 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. codeunique within tenant (case-insensitive); allowed chars letters/digits/hyphen.phoneE.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ÉLIMINAIREobligation 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;
getof A's agency from B → ForbiddenException. - Provisioning now creates PRINCIPAL agency; re-run idempotent;
defaultAgencyIdin 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_scopeon 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¶
- [ ]
agencymigration 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;
defaultAgencyIdin 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