Story CP-001: Landing + contract list, session cookie, all-or-none selection (no payment)¶
Module: customer-portal
Slice: CP-001 (architecture §11)
Side: [CUSTOMER] + [BACKEND]
Version target: [V0.0.0]
Priority: 1
Depends on: CP-S0; AUTH signed-link SignedLinkService.verifyAndOpen available; TENANT TenantConfigCache available; ING ContractRepository.findEligibleForFile available
Can develop concurrently with: None (Tier 1 — first behavioral slice)
Merge order: After CP-S0, before CP-002a
Estimated complexity: M
PRD User Stories: CP-01, CP-02, CP-03 (all-or-none part), CP-11
Design screen: CP-S1 (Contract List) — see docs/design/customer-portal-design.md §4 (CP-S1), §8 (micro-copy), §9 (components)
Wireframe: docs/wireframes/customer-portal/CP-001/ (prototype.tsx + README.md — screen CP-S1, generated)
[LEGAL-REVIEW] BLOQUANT — Magic-link as legally-opposable consent in Ivorian law =
INCERTAIN - avocat requis(PRD §legal status, audit red flag #5). Percompliance-discipline.md, anINCERTAINitem blocks production implementation. This story may be built behind the gate but does NOT enter the shippable backlog until legal clears the qualification. Renouvellement à l'identique only.
Objective¶
Deliver the first customer-facing slice: the assured clicks a signed link (WhatsApp/SMS), CP opens a cookie-bound session via AUTH, and renders the list of contracts up for renewal with the broker identity block. The customer can select contracts all-or-none (per-contract toggle arrives in CP-003 / v0.0.2). Selection is persisted server-side (resumable). No payment in this slice. This story also creates the full frozen CP database schema (per ADR-CP-05) and publishes the customer.session_opened Spring event (closes OQ-X7).
Backend Scope¶
Entities¶
Creates the full V1 column set, frozen v0.0.0 → V1 (ADR-CP-05); unused columns nullable with defaults.
- customer_sessions: customer_session_id UUID PK · tenant_id UUID NOT NULL · agency_id UUID NOT NULL · customer_file_id UUID NOT NULL · signed_link_id UUID NOT NULL · state TEXT (CP state-machine enum) · selected_contract_ids UUID[] NOT NULL DEFAULT {} · consent_content_version TEXT NULL · consent_ticked_at TIMESTAMPTZ NULL · inflight_transaction_id UUID NULL · session_token_hash TEXT (SHA-256 of cookie value) · correlation_id TEXT · country_code TEXT NOT NULL · created_at TIMESTAMPTZ NOT NULL · last_activity_at TIMESTAMPTZ NOT NULL · expires_at TIMESTAMPTZ (= signed_link.expires_at; not enforced until CP-005).
- payment_attempts (created now, used from CP-002b): payment_attempt_id UUID PK · customer_session_id UUID FK · tenant_id, agency_id, customer_file_id UUID NOT NULL · transaction_reference TEXT UNIQUE · idempotency_key UUID UNIQUE · provider TEXT · contract_ids UUID[] · amount_total_minor BIGINT (XOF integer minor units) · currency TEXT DEFAULT 'XOF' · status TEXT ENUM · provider_error_code TEXT NULL · mapped_reason TEXT NULL · attempt_index INT DEFAULT 1 · created_at · confirmed_at TIMESTAMPTZ NULL. (phone_last4 is added in CP-003 per arch §9.3 backfill posture — but since schema is frozen, include it nullable here too.)
- cp_readonly_sessions (created now, used from CP-006/V1): id UUID PK · signed_link_id UUID · tenant_id UUID · customer_file_id UUID · ip_hash TEXT · ua_hash TEXT · cookie_token_hash TEXT · created_at · expires_at TIMESTAMPTZ.
- mentions_legales_versions (created now; full page rendered in CP-006/V1, but the footer link + content-version read are in scope from V0): tenant_id UUID · content_version TEXT · content_html TEXT · published_at TIMESTAMPTZ · PK (tenant_id, content_version).
Migrations¶
db/migration/tenant/CP/V0_0_0_001__cp_baseline.sql— creates all four tables above with the full frozen schema (ADR-CP-05). Indexes per arch §2.1:customer_sessionsUNIQUE(tenant_id, signed_link_id),(tenant_id, customer_file_id), partial UNIQUE(customer_session_id) WHERE inflight_transaction_id IS NOT NULL, UNIQUE(session_token_hash);payment_attempts(tenant_id, customer_session_id, status), UNIQUE(transaction_reference), UNIQUE(idempotency_key);cp_readonly_sessionsUNIQUE(cookie_token_hash), BRIN(expires_at);mentions_legales_versionsPK(tenant_id, content_version).- A documented down step under
db/migration/tenant/CP/down/(operational, DBA-applied).
Service Layer¶
CustomerSessionService.openFromToken(token)— callsAUTH.SignedLinkService.verifyAndOpen(token); on AUTHENTICATED, inserts acustomer_sessionsrow (state=AUTHENTICATED), denormalisescountry_code+correlation_idfrom the AUTH response, mints a 32-byte opaque cookie token, stores its SHA-256 assession_token_hash, sets thecp_sessionHttpOnly cookie, and 303-redirects to/portail/session/contrats. Publishescustomer.session_openedexactly once per signed link on first authenticated render.CustomerSessionAuthFilter— on every/api/portail/session/*request, reads thecp_sessioncookie, looks upcustomer_sessions.session_token_hash, attachesTenantContext(tenant_id)from the row. No tenant from URL/header ever.ContractListService.list(tenantId, session)— callsING.ContractRepository.findEligibleForFile(tenant, file)(status PENDING_RENEWAL, prime > 0, not flagged), hydrates broker identity fromTenantConfigCache, builds the compact response (arch §3.2, ≤6 KB gzipped). Returns the greeting name for the BrokerHeader/landing (individual → first name; legal entity or name unavailable → no name). Surfaces a "list updated" soft notice if the resolved set differs from the campaign-issuance set. Replaces missing garanties/exclusions with the fixed French copy and emitscustomer_contract_details_missing(deduped once per session per contract).SelectionService.replace(tenantId, session, ids[])— validates each id against the latest eligible set, strips ineligible ids, persists the canonicalselected_contract_ids, returns the canonical set. (V0.0.0 = all-or-none; the client sends either the full eligible set or empty.)
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| GET | /api/portail/{token} |
— | 303 → /portail/session/contrats + Set-Cookie: cp_session |
signed-link (AUTH) |
| GET | /api/portail/session/contrats |
— | 200 compact JSON (arch §3.2): {v, tnt{...}, ses{...}, ctr[...]} |
cookie |
| PUT | /api/portail/session/selection |
["c1","c3"] |
200 canonical set (ineligible stripped) | cookie |
| POST | /api/portail/session/support/click |
{channel: "tel"\|"wa"} |
204 (audit-only) | cookie |
- All XHR carry
X-API-Version: 1via theapiClient.tswrapper (arch §3.0). - JSON responses:
Cache-Control: private, max-age=0, must-revalidate+ strong ETag; HTML:no-store(arch §3.4).
Validation Rules¶
- Selection ids must be a subset of the current eligible set; non-subset ids silently stripped (returned in body), HTTP 200.
channel∈ {tel,wa}.- Cookie value never logged;
tenant_idonly ever read from the cookie-bound row. - Money:
prmis XOF integer minor units; client renders with space thousands separator + 0 decimals (185 000 XOF).
Multi-Tenant Considerations¶
tenant_id is resolved solely from the cookie-bound customer_sessions row (never URL/header). Every repository call carries tenant_id as the first WHERE predicate (Rule #1). Cross-tenant access is structurally impossible (cookie-bound session is the only seed). Schema identical across SHARED + BYO profiles; JdbcClient wired to the routing datasource. country_code denormalised on the session (V2-safe for SN/BJ).
Audit & Logging¶
Emit via outbox (D4), correlation_id propagated (D16). Failure to audit must NOT roll back the business action.
- customer_session_opened — on session row insert.
- customer_contract_list_viewed — on GET /contrats (customer_session_id, contract_ids[], inflight_transaction_id nullable).
- customer_contract_details_missing — on a row with null garanties/exclusions (customer_session_id, contract_id, missing_fields[]), deduped once per session per contract.
- customer_selection_changed — on PUT /selection delta (added_contract_ids[], removed_contract_ids[], selection_size).
- customer_offline_banner_shown — client beacon, best-effort, no retry (trigger_reason).
- customer_support_contact_clicked — on POST /support/click (channel).
Frontend Scope¶
Pages & Routes¶
/portail/session/contrats→ContractListPage(route-split, lazy). TheWelcomePageshell adapter wires the/portail/{token}entry (server 303s to the list).
Components¶
Use design-system primitives. New customer components (design §9, file paths authoritative):
- BrokerHeader (frontend/src/customer/components/BrokerHeader.tsx) — sticky top, on EVERY screen (CP-NFR-13). Logo (48×48, signed URL, initials fallback) + commercial name + Agrément CIMA {agrement_number} + click-to-call chip + click-to-WhatsApp chip. WhatsApp chip hidden when agency.whatsapp_phone is null. Chips emit customer_support_contact_clicked.
- StepIndicator (frontend/src/components/ui/StepIndicator.tsx, shared) — 3-step Choisir → Vérifier → Payer; step 1 active on CP-S1 (design D1).
- ContractRow (frontend/src/customer/components/ContractRow.tsx) — tap-to-expand accordion (zero network call on expand, design D3); checkbox (≥44px hit area); sync dot states idle | pending | saved | error with text label (color never the only indicator, design D4).
- StickyPayBar (frontend/src/customer/components/StickyPayBar.tsx) — fixed bottom, shows count + total; in V0.0.0 the Pay CTA is present but navigates to nothing yet (payment lands in CP-002). Slides up on first selection.
- NetworkBanner (frontend/src/customer/components/NetworkBanner.tsx) — amber banner below BrokerHeader within 5 s of XHR failure (design D7); role="status" aria-live="polite".
- InlineBanner, Skeleton, Badge elevated to HIGH priority (design §9).
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | BrokerHeader skeleton (logo rect + 2 shimmer lines), StepIndicator placeholder, ContractRow skeletons (no bare spinner) | — |
| Empty | If ALL_PAID → route to CP-S8 (handled server-side, R-CP-004). If operator excluded all → InlineBanner info + click-to-call chip |
« Aucun contrat à renouveler pour le moment. Contactez votre cabinet. » |
| Error | XHR load failure → InlineBanner error + Button ghost « Réessayer »; BrokerHeader still rendered (CP-NFR-13) | « Impossible de charger vos contrats. Vérifiez votre connexion et réessayez. » |
| Offline / low network | NetworkBanner within 5 s; selection mutations queue; sync dot amber while pending; retry every 10 s w/ backoff (cap 30 s); on reconnect dot turns green | « Connexion instable. Vos choix sont enregistrés. » |
| Success | Contracts listed with checkboxes; StickyPayBar live-reflects count + total; server selection authoritative on reload | StickyPayBar 0: « Sélectionnez au moins un contrat à régler. » · 1: « Payer 1 contrat — {total} XOF » · N: « Payer {N} contrats — {total} XOF » |
Greeting (above the fold, design D2): individual « Bonjour {prénom}, » · legal entity / name unavailable « Bonjour, »; context sentence « Votre cabinet vous invite à renouveler vos contrats qui arrivent à échéance. » Section label: « Vos contrats à renouveler » · count badge « 1 contrat » / « {N} contrats ». Sync dot labels: saved « enregistré » · pending « en cours… » · error « non enregistré ». Soft notice when the resolved list dropped rows vs campaign time (R-CP-002): « Certains contrats ne sont plus éligibles. Votre liste a été mise à jour. » (InlineBanner info, auto-dismiss after 8 s). Missing per-contract detail copy (R-CP-005): « Détails non fournis. Demandez à votre conseiller. » Footer link: « Mentions légales et données personnelles » (V0 = link only; full CP-S9 page render is CP-006/V1).
Responsive Behavior¶
Mobile-first 360 px portrait (primary target); touch targets ≥ 44 px; no hover-only interactions; Android 8+ default browser. Single-column stack at 360; 414 = larger internal padding; 768 = centered 600px column, BrokerHeader single-row; 1024–1440 = centered 500px content column with warm side margins (design §6 — the portal always reads as a mobile experience; StickyPayBar stays inside the 500px column). Date JJ/MM/AAAA, phone +225 XX XX XX XX, currency 15 000 XOF.
Connectivity Behavior (CUSTOMER stories only)¶
- Initial payload budget: landing critical path ≤ 200 KB gzipped (floor ~107 KB per arch §8.1); contract-list JSON ≤ 6 KB gzipped.
- Survives reload: server-side
selected_contract_idsis authoritative — a reload within the link window restores the selection. - Survives disconnect mid-flow: selection mutations debounced ~500 ms, POSTed; on failure they retry with backoff capped at 30 s; the server set wins on reload. No localStorage / service worker / IndexedDB (Rule #11).
- Signed-link reuse: re-clicking the same link re-opens the same session (cookie re-issued).
Interactions¶
- Selection debounced ~500 ms then
PUT /selection; optimistic UI with amber→green "saved" dot. - Expand row reveals garanties + exclusions already present in the list payload (no network call).
- Footer total recomputes from selected
prmvalues; XOF formatting client-side.
Acceptance Criteria¶
AC-001.1: Landing opens a cookie-bound session
Given AUTH verifies my signed link as AUTHENTICATED
When I GET /api/portail/{token}
Then CP inserts a customer_sessions row, sets an HttpOnly cp_session cookie, 303-redirects to /portail/session/contrats, publishes customer.session_opened once, and emits customer_session_opened
AC-001.2: Contract list renders with broker identity and per-contract details
Given an open session
When I GET /api/portail/session/contrats
Then I see the BrokerHeader (logo, commercial name, Agrément CIMA, click-to-call + click-to-WhatsApp chips), the greeting « Bonjour {prénom}, » + context sentence, and every eligible contract (branche, n° police, période, prime), each row expandable to garanties + exclusions; missing detail shows « Détails non fournis. Demandez à votre conseiller. » and emits customer_contract_details_missing; the load emits customer_contract_list_viewed
AC-001.3: All-or-none selection persists server-side
Given the contract list
When I select (all) or clear contracts
Then the change is debounced ~500 ms and persisted to PUT /selection; ineligible ids are stripped and returned canonical; the StickyPayBar shows the count and total in XOF (« Payer {N} contrats — {total} XOF »); a reload restores the server-side selection
AC-001.4: Network drop shows banner and keeps selection safe
Given a selection mutation fails (XHR timeout / navigator offline / 5xx) for > 5 s
When the failure persists
Then the NetworkBanner « Connexion instable. Vos choix sont enregistrés. » appears below the BrokerHeader, the saved dot stays amber (« en cours… »), a retry runs every 10 s, and on the next successful XHR the banner clears and the dot turns green (« enregistré »); customer_offline_banner_shown is beaconed best-effort
Compliance Rules¶
- Magic-link as opposable consent =
INCERTAIN - avocat requis([LEGAL-REVIEW], BLOQUANT) — see header. Blocks production ship. - CIMA Reg. 01-24 mandatory information (
PRÉLIMINAIRE, CP-TV-01): broker identity surface (name, agrément, contestation contact) rendered on every screen including this one via BrokerHeader (CP-NFR-13, design §10 placement rule 1); never the platform brand without the broker brand. - Loi CI 2013-450 minimisation (
VÉRIFIÉ): CP collects no customer data here beyond the selection set; DOB/RCCM is AUTH-owned and never re-collected.
Standards & Conventions¶
docs/standards/critical-rules.md— Rule #1 tenant isolation, Rule #2 money (XOF minor units), Rule #3 audit, Rule #11 no client offline engine, Rule #12 Slow-3G verify.docs/standards/multi-tenant-model.md— SHARED/BYO uniform code path; datasource routing.docs/standards/connectivity-low-bandwidth.md— payload budget, server-side state, offline banner.docs/standards/api-guidelines.md— envelope, RFC 7807 errors, ETag, header versioning.docs/standards/react-typescript-guidelines.md— apiClient.ts wrapper, TanStack Query.docs/standards/design-system.md+design-principles.md— components, no silent failure.docs/design/customer-portal-design.md— §4 CP-S1 wireframe + 5 states, §8 micro-copy, §9 components.docs/standards/compliance-discipline.md—[LEGAL-REVIEW]markers.
Testing Requirements¶
Unit Tests¶
- Eligible-set filtering (PENDING_RENEWAL, prime > 0, not flagged).
- Selection stripping of ineligible ids; canonical-set response.
- Missing-detail copy substitution + dedupe of customer_contract_details_missing.
- Greeting resolution (individual first name vs legal-entity « Bonjour, »).
Integration Tests¶
- GET /{token} → 303 + Set-Cookie cp_session (HttpOnly, SameSite=Lax) + customer.session_opened published.
- Tenant isolation: tenant B cannot load tenant A's session or contracts (arch §13.2).
- PUT /selection strips a contract id from another tenant (silently).
- Low-bandwidth: contract-list JSON ≤ 6 KB gzipped.
What QA Will Validate¶
- Renders at 360 / 768 / 1024 / 1440; all 5 UI states; French copy exact (greeting, sync-dot labels, error, empty); Slow-3G smoke (TTI ≤ 6 s, first row ≤ 4 s); reload restores selection.
Out of Scope¶
- Any payment, summary, consent, receipt (CP-002a/b/c).
- Per-contract toggle + phone-last-4 (CP-003 / v0.0.2 — V0.0.0 is all-or-none).
- Status polling (CP-003).
- Link TTL / expiry enforcement, attempt counter, rate limiting (CP-005 / v0.1.0).
- Full Mentions-légales page render (CP-S9), "lien remplacé" (CP-S7), "déjà réglé" re-download (CP-S8/CP-13) — CP-006 / V1. Only the footer link + content-version read are in V0.
- DOB/RCCM challenge (AUTH).
Definition of Done¶
- [ ]
V0_0_0_001__cp_baseline.sqlcreates all 4 tables with frozen schema + indexes - [ ] GET /{token}, GET /contrats, PUT /selection, POST /support/click implemented
- [ ]
customer.session_openedSpring event published once per signed link - [ ] All frontend pages render at 360 / 768 / 1024 px (and 500px column at 1440)
- [ ] All 5 UI states implemented (loading, empty, error, offline/low-network, success)
- [ ] French micro-copy matches design §8 exactly (greeting, context, sync-dot labels, error, empty, StickyPayBar)
- [ ] StepIndicator step 1 + BrokerHeader present on the screen
- [ ] AC-001.1 … AC-001.4 pass manually
- [ ] Audit entries for session_opened, contract_list_viewed, contract_details_missing, selection_changed, offline_banner_shown, support_contact_clicked
- [ ] Tenant isolation verified (cookie-seeded tenant_id; both SHARED + BYO profiles)
- [ ] Initial payload < 200 KB gzipped; contract-list JSON ≤ 6 KB gzipped
- [ ] Flow tested under throttled "Slow 3G"
- [ ] Resumability verified (reload restores selection)
- [ ] No silent failures on network errors