Aller au contenu

Story NOTIF-005: Functional opt-out link (end-to-end, server-side suppression)

Module: notifications Slice: v0.0.1 NOTIF — functional opt-out link (PRD V0 Envelope §"Découpage par sous-version" row v0.0.1; lock #1 "Lien d'opt-out") Side: [BACKEND] (+ one minimal server-rendered confirmation page) Version target: V0 Sub-version: v0.0.1 Priority: 9 (first v0.0.1 story; after all v0.0.0 stories merged) Depends on: NOTIF-001a, NOTIF-001b, NOTIF-001c (v0.0.0 dispatch + SMS adapter + suppression read/write), AUTH signed-link signer (for the opt-out token) Can develop concurrently with: NOTIF-006 Merge order: Before NOTIF-006 (NOTIF-006 message-body copy references the opt-out link this story creates) Estimated complexity: M PRD User Stories: NOTIF-US-07 (opt-out path, link variant); PRD V0 Envelope lock #1 + #5 Wireframe: N/A — single static server-rendered confirmation page; copy is inline in this story


⚠️ [ARCH-PENDING] — statut PRÉLIMINAIRE

The architecture document docs/architecture/notifications-arch.md is v0.0.0-only. v0.0.1 is only a forward-compatibility checkpoint there ("consent UI"), not a designed slice. This story is drafted from the PRD V0 Envelope + the v0.0.0 architecture's reserved hooks. It MUST be reconciled with an ARCHITECT pass for v0.0.1 before dev-start. Treat every API path/field below as a proposal, not a locked contract.


Objective

In v0.0.0 the only way a customer opts out is by replying STOP to the SMS. v0.0.1 adds a clickable opt-out link in every outbound message (PRD V0 lock #1: "Lien d'opt-out dans chaque message sortant — format court, sans authentification supplémentaire, idempotent"). Clicking it must produce a server-side suppression that is immediately effective — the next dispatch to that phone is dropped and traced. This closes the "lien d'opt-out fonctionnel de bout en bout (suppression effective dans le NOTIF côté serveur)" requirement of v0.0.1.


Backend Scope

Entities

Reuses the v0.0.0 suppression table (introduced in NOTIF-001c). No new table. - suppression (tenant_id, msisdn_e164, channel, reason, source_notification_id, created_at) — this story writes rows with reason = 'customer_opt_out_link'.

New (proposed, subject to ARCHITECT v0.0.1 pass): an opt-out token map so a short URL resolves to (tenant_id, msisdn, notification_id) without exposing PII in the URL: - opt_out_token (token TEXT PK, tenant_id UUID NOT NULL, msisdn_e164 TEXT NOT NULL, notification_id UUID NOT NULL, issued_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ NULL).

Migrations

  • Vxxx__notif_opt_out_token.sqlCREATE TABLE opt_out_token (...) per above. Index on token. token is a high-entropy opaque string (≥ 128 bits), not guessable, not PII-bearing.

Service Layer

  • OptOutTokenService.issueFor(notification) — called at dispatch time (hook added to the v0.0.0 SmsDispatcher / WA cascade) to mint a token and build the short URL {platform_base}/o/{token}. Idempotent per notification_id (re-mint returns the same token).
  • OptOutLinkController.handle(token)GET /o/{token}: resolves the token, writes/upserts the suppression row scoped by tenant_id, marks the token consumed_at, returns a minimal FR confirmation page. Idempotent: a second click on the same link returns the same confirmation, writes no duplicate suppression row.

API Endpoints

Method Path Request Response Auth
GET /o/{token} path token 200 HTML confirmation page (FR) None (opaque token is the credential — PRD lock #1 "sans authentification supplémentaire")

Validation Rules

  • token must exist and not be malformed → unknown/expired token returns a neutral 200 page "Lien expiré ou invalide." (never reveals whether the token existed — anti-enumeration).
  • Suppression upsert is keyed (tenant_id, msisdn_e164, channel); channel set to ALL for a link opt-out (suppresses both SMS and WhatsApp for that phone within the tenant).
  • Opt-out is per-tenant (never global) — same isolation rule as NOTIF-001c.

Multi-Tenant Considerations

  • The token carries the tenant_id; the suppression row is written scoped by that tenant_id. A token for tenant A can never create a suppression in tenant B.
  • Works under both v0.0.0 DB profiles (SHARED, BYO) — no cross-tenant table access.

Audit & Logging

  • On a successful link opt-out, emit NOTIF_SUPPRESSED_OPT_OUT (same event used by NOTIF-001c) with reason = customer_opt_out_link and the pseudonymized phone (PiiHasher, per AUDIT contract). Required fields per audit-log-prd §B.3.
  • The next dispatch dropped by this suppression continues to emit NOTIF_SUPPRESSED_OPT_OUT at dispatch time (already wired in NOTIF-001c).

Frontend Scope

Pages & Routes

  • GET /o/{token} returns one server-rendered static FR page (no SPA, no JS required — must load on 3G in < 2 s, < 50 KB).

UI States (ALL REQUIRED)

State Behavior French Copy
Loading Server-rendered; no client loading state
Empty N/A (single-purpose page)
Error Unknown/expired/used token → neutral page "Lien expiré ou invalide. Si vous souhaitez ne plus recevoir de messages, répondez STOP au SMS."
Offline / low network Static HTML, cache-friendly; if fetch fails the customer simply retries the link
Success Confirmation that they will no longer be contacted "C'est fait. Vous ne recevrez plus de messages de {tenant_name} pour ce renouvellement."

Responsive Behavior

  • Single-column, mobile-first, 16 px min body, 360 / 768 / 1024 px. No media. Matches the privacy-notice page treatment (design §1.4).

Connectivity Behavior (CUSTOMER stories only)

  • Page payload < 50 KB, no JS dependency, openable from any SMS/WA client's in-app browser. The opt-out effect is committed server-side on the GET; no client retry logic needed.

Acceptance Criteria

AC-005.1: Opt-out link suppresses immediately
Given a customer received a message carrying an opt-out link {platform_base}/o/{token}
When the customer opens that link
Then NOTIF writes a suppression row scoped to the message's tenant_id with reason customer_opt_out_link
And the confirmation page "C'est fait..." is shown
And a subsequent dispatch to the same phone in that tenant is dropped and traced NOTIF_SUPPRESSED_OPT_OUT
AC-005.2: Opt-out link is idempotent
Given a customer already opened their opt-out link once
When the same link is opened again (e.g. retry, prefetch, multiple taps)
Then no duplicate suppression row is created
And the same confirmation page is returned with HTTP 200
AC-005.3: Token does not leak identity and resists enumeration
Given an attacker submits a random or guessed {token}
When GET /o/{token} is called
Then the response is a neutral 200 page that reveals nothing about whether the token existed
And no suppression is created for any tenant

Compliance Rules

  • ARTCI mitigation #1 (PRD V0 Envelope "Verrouillé"): opt-out link in every outbound message, short, no extra auth, idempotent — this story delivers the functional end of that mitigation. Statut PRÉLIMINAIRE[LEGAL-REVIEW]: confirm with counsel that an unauthenticated opaque-token opt-out is acceptable evidence of objection under loi n°2013-450.
  • The opt-out is the customer's "droit d'opposition" surface named in the privacy notice (design §1.4). Wording must stay consistent with that page.
  • ARTCI B5 + B10 production BLOQUANT (PRD V0 Envelope) still applies to the underlying real send; unaffected by this story.

Standards & Conventions

  • docs/standards/critical-rules.md (tenant isolation; audit-log obligation).
  • docs/standards/java-spring-guidelines.md (Spring Data JDBC, controller conventions).
  • docs/standards/api-guidelines.md (header-based versioning; the /o/{token} short path is intentionally outside /api — confirm in ARCHITECT v0.0.1 pass).
  • docs/standards/connectivity-low-bandwidth.md (< 50 KB static page, 3G budget).
  • docs/standards/compliance-discipline.md (PRÉLIMINAIRE handling).

Testing Requirements

Unit Tests

  • Token mint is idempotent per notification.
  • Suppression upsert writes exactly one row per (tenant_id, msisdn, channel).
  • Unknown token → neutral page, no DB write.

Integration Tests

  • End-to-end: dispatch → open opt-out link → next dispatch to same phone is dropped + NOTIF_SUPPRESSED_OPT_OUT emitted.
  • Tenant isolation: token for tenant A never suppresses in tenant B.
  • Double-open → single suppression row.

What QA Will Validate

  • Clicking the link in a real SMS opens a page in < 2 s on a throttled "Slow 3G" profile, and the customer is genuinely suppressed thereafter.

Out of Scope

  • Consent checkbox UI on the CP landing page (owned by CP; copy supplied by NOTIF-006).
  • Cross-border disclosure page content (NOTIF-006).
  • Tenant-override of the privacy/opt-out branding (V1).
  • Re-subscription / opt-back-in flow (V1).
  • Operator visibility of opt-outs (V1).

Definition of Done

  • [ ] GET /o/{token} implemented, returns FR confirmation page
  • [ ] Confirmation page renders correctly at 360 / 768 / 1024 px
  • [ ] All 5 UI states handled (success + error neutral page are the live ones)
  • [ ] French micro-copy matches this story exactly
  • [ ] All acceptance criteria pass manually
  • [ ] Unit + integration tests written and passing
  • [ ] Tenant isolation verified (token-scoped tenant_id; both V0 DB profiles tested)
  • [ ] [CUSTOMER] confirmation page < 50 KB, loads < 2 s on Slow 3G
  • [ ] [CUSTOMER] opt-out link idempotent on reload / multi-tap
  • [ ] NOTIF_SUPPRESSED_OPT_OUT audit entry present on link opt-out
  • [ ] No silent failures on network errors
  • [ ] [ARCH-PENDING] reconciled with the ARCHITECT v0.0.1 pass before merge to a release branch