Story OP-001: Backend cohort éligibilité — GET /api/operator/contracts?eligible=true¶
Module: operator-console
Slice: Architecture §11 OP-001a + sous-set de §3.3 Contrats
Side: [BACKEND]
Version target: V0.0.0
Priority: 2
Depends on: OP-000 (ContractEligibilityQueryAdapter, RBAC filter, exception handler)
Can develop concurrently with: OP-002, OP-015
Merge order: Any order after OP-000 ; avant OP-005 (frontend liste)
Estimated complexity: S
PRD User Stories: V0 envelope V0.0.0 (cohort simplifiée renewal_window_days)
Wireframe: N/A — backend only
Objective¶
Exposer l'endpoint REST que le frontend (OP-005) et l'UI confirmation modale appellent pour afficher le compte de contrats éligibles AVANT déclencher la création + envoi d'une campagne. La logique d'éligibilité réelle vit dans ContractQueryService.findEligible (ING) — cette story ne fait que la projection REST scoped tenant.
Backend Scope¶
Entities¶
Aucune entité OP créée. Lit contracts (ING owner).
Migrations¶
Aucune (toutes les tables ont été créées en OP-000).
Service Layer¶
ContractEligibilityController(@RestController("/api/operator/contracts")) — méthodeeligibleContracts(...).- Délègue à
ContractEligibilityQueryAdapter.findEligible(tenantId, agencyId, windowDays)de OP-000. windowDayslu depuisTenantConfigCache.getRenewalWindowDays(tenantId)(default 30). Pas de paramètre query — la fenêtre est settings-driven (FR-OP-01).- Pagination curseur (D6, 25/page par défaut).
cursorquery param opaque base64.
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
GET |
/api/operator/contracts?eligible=true&cursor=&pageSize=25 |
— | { data: ContractSummary[], nextCursor, hasMore, totalEligibleCount } |
agency_operator, supervisor, cabinet_admin |
ContractSummary JSON :
{
"id": "uuid",
"customerName": "KOUAKOU Aya",
"phone": "+225 07 02 11 22",
"productBranch": "Auto",
"amount": 85000,
"dueDate": "2026-05-29",
"agencyId": "uuid"
}
Validation Rules¶
eligibledoit êtretrue(V0.0.0 ne supporte que cette valeur). Toute autre valeur → 400.pageSize∈ [1, 100], default 25.cursor: base64 valide ou absent → sinon 400OP_INVALID_CURSOR.
Multi-Tenant Considerations¶
tenant_iddu JWT, scope obligatoire.agency_id: si role=AGENCY_OPERATOR,ContractEligibilityQueryAdapterreçoit l'agencyIddepuis le contexte (V0.0.0 : pas de filtrage agence actif —AgencyScopeFilterdésactivé ; tous les contrats du tenant remontent). En V0.0.2 (OP-010), le filtrage s'active automatiquement.- Aucun lookup cross-tenant possible.
Audit & Logging¶
- Pas d'audit event pour cette lecture (per V1 PRD §B.6, seuls les writes sont auditrés).
- MDC enrichi :
tenant_id,endpoint=GET /contracts.
Acceptance Criteria¶
AC-001: L'endpoint retourne les contrats éligibles dans la fenêtre `renewal_window_days`
Given un tenant avec renewal_window_days=30 et 12 contrats dont 8 dans [today, today+30]
And aucun de ces contrats n'est dans une campagne active (FR-OP-04)
When GET /api/operator/contracts?eligible=true est appelé
Then la réponse contient 8 entrées avec totalEligibleCount=8 et hasMore=false.
AC-002: Les contrats déjà actifs dans une campagne sont exclus (FR-OP-04)
Given 5 contrats éligibles
And 2 d'entre eux ont une campaign_contract en statut INCLUS d'une campagne BROUILLON
When GET /api/operator/contracts?eligible=true est appelé
Then la réponse contient les 3 contrats restants seulement.
AC-003: Isolation tenant
Given un tenant A avec 5 contrats éligibles et un tenant B avec 7
When un operator du tenant A appelle l'endpoint
Then il reçoit exactement les 5 contrats de A ; aucun de B (vérifié par ID).
Compliance Rules¶
- PRD V0 §4 ligne OP : cohort simplifiée
renewal_window_days—VÉRIFIÉ. - ARTCI minimisation : la réponse n'inclut pas de DOB (FR-OP-06 — DOB masqué en liste).
VÉRIFIÉ.
Standards & Conventions¶
docs/standards/critical-rules.md— rules #1, #2 (XOF integer minor units).docs/standards/api-guidelines.md— pagination curseur, error envelope.docs/standards/database-guidelines.md— index(tenant_id, due_date)surcontracts(déclaré dans TENANT/ING).
Testing Requirements¶
Unit Tests¶
ContractEligibilityControllerTest: 400 sieligible=false; 400 si cursor invalide ; pageSize bornes.
Integration Tests¶
EligibleContractsIT(AC-001, AC-002, AC-003) avec Postgres Testcontainer + Keycloak Testcontainer + un set ING contracts fixture.- Test pagination : 30 contrats éligibles → 2 pages de 25 + 5 ; cursor opaque round-trip.
What QA Will Validate¶
curlavec JWT operator → 200 + JSON conforme.- Performance : p99 < 500 ms pour 1000 contrats (index
(tenant_id, due_date)couvre,idx_cc_tenant_contract_statuscouvre le NOT EXISTS).
Out of Scope¶
- Création de campagne → OP-002.
- Filtres avancés (branche, plage montant, plage date) → V1 (US-OP-03 ad hoc selector).
- Recherche client → V1 (US-OP-10).
- Cohort planifiée
@Scheduled→ V1 (FR-OP-01 / OP-011 arch).
Definition of Done¶
- [ ] Endpoint
GET /api/operator/contracts?eligible=trueretourne 200 + JSON conforme - [ ] AC-001 / AC-002 / AC-003 passent en intégration
- [ ] Tenant isolation prouvée (AC-003)
- [ ] AgencyScopeFilter inactif (V0.0.0) — pas d'erreur si role=AGENCY_OPERATOR
- [ ] p99 < 500 ms sur dataset 1000 contrats (test perf optionnel)
- [ ] Pagination curseur idempotente (round-trip)
- [ ] DOB jamais présent dans la réponse
- [ ] Aucune string FR en hardcoded (l'endpoint est headless)
- [ ] Unit + integration tests passent