Aller au contenu

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). Per compliance-discipline.md, an INCERTAIN item 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_sessions UNIQUE (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_sessions UNIQUE (cookie_token_hash), BRIN (expires_at); mentions_legales_versions PK (tenant_id, content_version).
  • A documented down step under db/migration/tenant/CP/down/ (operational, DBA-applied).

Service Layer

  • CustomerSessionService.openFromToken(token) — calls AUTH.SignedLinkService.verifyAndOpen(token); on AUTHENTICATED, inserts a customer_sessions row (state=AUTHENTICATED), denormalises country_code + correlation_id from the AUTH response, mints a 32-byte opaque cookie token, stores its SHA-256 as session_token_hash, sets the cp_session HttpOnly cookie, and 303-redirects to /portail/session/contrats. Publishes customer.session_opened exactly once per signed link on first authenticated render.
  • CustomerSessionAuthFilter — on every /api/portail/session/* request, reads the cp_session cookie, looks up customer_sessions.session_token_hash, attaches TenantContext(tenant_id) from the row. No tenant from URL/header ever.
  • ContractListService.list(tenantId, session) — calls ING.ContractRepository.findEligibleForFile(tenant, file) (status PENDING_RENEWAL, prime > 0, not flagged), hydrates broker identity from TenantConfigCache, 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 emits customer_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 canonical selected_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: 1 via the apiClient.ts wrapper (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_id only ever read from the cookie-bound row.
  • Money: prm is 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/contratsContractListPage (route-split, lazy). The WelcomePage shell 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_ids is 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 prm values; 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.sql creates all 4 tables with frozen schema + indexes
  • [ ] GET /{token}, GET /contrats, PUT /selection, POST /support/click implemented
  • [ ] customer.session_opened Spring 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