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.mdis 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.sql—CREATE TABLE opt_out_token (...)per above. Index ontoken.tokenis 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.0SmsDispatcher/ WA cascade) to mint a token and build the short URL{platform_base}/o/{token}. Idempotent pernotification_id(re-mint returns the same token).OptOutLinkController.handle(token)—GET /o/{token}: resolves the token, writes/upserts the suppression row scoped bytenant_id, marks the tokenconsumed_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¶
tokenmust 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 toALLfor 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 thattenant_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) withreason = customer_opt_out_linkand 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_OUTat 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_OUTemitted. - 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_OUTaudit 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