Story NOTIF-001d: Country-profile resolver + provider strategy bean wiring (CI seeded)¶
Module: notifications
Slice: NOTIF-001 sub-slice d (of NOTIF-001 in architecture §11)
Side: [BACKEND]
Version target: V0.0.0
Sub-version: v0.0.0 (architecture-backed)
Priority: 2 (parallelizable with NOTIF-001a)
Depends on: NOTIF-S0, TENANT must own the country_profile columns + CI seed row (architecture §9.1 step 4 — TENANT-owned migration)
Can develop concurrently with: NOTIF-001a
Merge order: Before NOTIF-001b (NOTIF-001b's SmsDispatcher resolves the provider via this slice)
Estimated complexity: S
PRD User Stories: N/A — Technical Story (supports NOTIF-US-01 / NFR-10 provider-agnostic)
Wireframe: N/A — backend only
Objective¶
Implement the country-profile-driven provider resolver. At dispatch time, NOTIF looks up country_profile.notif_provider_sms and notif_provider_wa from the tenant's country_code, and selects the corresponding Spring @Component strategy bean keyed by provider_id (letstalk_sms_ci, meta_wa_cloud_ci, …). Only CI is seeded in v0.0.0 — but the wiring is Day-1 multi-country so adding a country in V1 is one row + one adapter class, zero NOTIF code change (NOTIF-ADR-05 / NFR-10).
Backend Scope¶
Entities¶
None (this story reads country_profile but does not own its schema — see Migrations).
Migrations¶
None owned by this story. TENANT owns V0_0_0__notif_seed_country_profile_notif_columns.sql (architecture §9.1 step 4 — listed in NOTIF architecture for traceability; the migration adds notif_provider_sms, notif_provider_wa, sms_sender_id_default columns to country_profile and seeds the CI row). This story HARD-DEPENDS on that migration being available before development starts.
Service Layer¶
CountryProfileResolver:String resolveSmsProviderId(String countryCode)— readscountry_profile.notif_provider_smsfor the given country. Cache via the existingTenantConfigCacheinfrastructure (the country-profile cache is shared with TENANT — architecture §1.2 row 3). Returns the provider key (e.g.letstalk_sms_ci).String resolveWaProviderId(String countryCode)— same fornotif_provider_wa.String resolveSmsSenderIdDefault(String countryCode)— for adapter use at submit time.Optional<...>API — empty for an unseeded country (NOTIF-001a maps empty → 400tenant_country_unsupported).SmsProviderRegistry— Spring@Componentthat holds aMap<String, SmsProvider>keyed byprovider_id(Spring auto-collects beans implementingSmsProviderand discriminates via aString providerId()method on the interface).SmsProvider get(String providerId)— returns the bean or throwsUnknownProviderException(500 — operational bug, not a user-facing error).WaProviderRegistry— symmetric forWhatsAppProvider(the interface itself ships in NOTIF-003; the registry hook is wired here so NOTIF-003 only adds the adapter).- Wiring point in
NotificationService.dispatch(...)(NOTIF-001a): the SMSdelivery_attempt.provider_idis set fromCountryProfileResolver.resolveSmsProviderId(tenant.country_code)at insert time — NOT hard-coded. NOTIF-001b'sSmsDispatcherresolves the adapter fromSmsProviderRegistry.get(provider_id)at submit time.
API Endpoints¶
None.
Validation Rules¶
tenant.country_codeMUST have acountry_profilerow with non-nullnotif_provider_sms(andnotif_provider_waeven though v0.0.0 doesn't use WA at dispatch time — the WA resolver returning empty would block NOTIF-003 at runtime). Validation belongs in NOTIF-001a's request path; this story provides the resolver.
Multi-Tenant Considerations¶
country_profileis global (not tenant-scoped) — it is keyed bycountry_codeand shared across tenants of the same country. Critical-rule #1 doesn't apply to this table (it's reference data, not customer data).- Both V1 DB profiles supported:
country_profilelives onplatformDataSource(TENANT owns it). - No tenant impersonation possible — the resolver only consumes a
country_code, which itself is resolved from the JWT-scoped tenant in NOTIF-001a.
Audit & Logging¶
- No audit event owned by this story.
- A failed resolver call (unseeded country) is surfaced as a 400 from NOTIF-001a; no audit event (per OQ-NOTIF-08 — validation rejects don't audit).
- A failed registry lookup at submit time (
UnknownProviderException) is logged as ERROR withcorrelation_idand triggers an alert — this is operational data integrity, not a customer-facing flow.
Frontend Scope¶
N/A — backend only.
Acceptance Criteria¶
AC-001d.1: CI resolver returns seeded providers
Given the `country_profile` table has a seeded row for `country_code='CI'` with `(notif_provider_sms='letstalk_sms_ci', notif_provider_wa='meta_wa_cloud_ci', sms_sender_id_default='PAPILLON')`
When `CountryProfileResolver.resolveSmsProviderId("CI")` is called
Then it returns `Optional.of("letstalk_sms_ci")`
And `resolveWaProviderId("CI")` returns `Optional.of("meta_wa_cloud_ci")`
And `resolveSmsSenderIdDefault("CI")` returns `Optional.of("PAPILLON")`
AC-001d.2: Unseeded country returns empty + adding a country requires zero NOTIF source change
Given a tenant with `country_code='SN'` and NO `country_profile.SN` row
When the resolver is called with "SN"
Then it returns `Optional.empty()`
And NOTIF-001a maps this to a 400 `tenant_country_unsupported`
When (forward-compat verification) a second country row is inserted (`SN`, `letstalk_sms_sn`, `meta_wa_cloud_sn`, 'PAPILLON_SN') AND a `letstalk_sms_sn` SmsProvider bean is added to the Spring context
Then `SmsProviderRegistry.get("letstalk_sms_sn")` returns the new bean without any source change to `CountryProfileResolver`, `NotificationService`, or `SmsDispatcher`
Compliance Rules¶
- None directly. The country-profile resolver is the architectural seam that lets ARTCI mitigations (configured per country) stay per-country without spreading
if (country == "CI")checks through NOTIF. Status discipline: when a second country is added, its mitigation set must be VÉRIFIÉ before the seed row goes in.
Standards & Conventions¶
docs/standards/critical-rules.mddocs/standards/java-spring-guidelines.md(@Componentstrategy pattern, Spring auto-collect viaMap<String, T>injection)docs/standards/database-guidelines.mddocs/standards/multi-datasource-patterns.mddocs/standards/multi-tenant-model.md(country-profile resolution rules)docs/standards/tech-stack.md
Testing Requirements¶
Unit Tests¶
CountryProfileResolverTest— CI seeded → hits; SN unseeded → empty.SmsProviderRegistryTest— knownprovider_id→ returns bean; unknown → throwsUnknownProviderException.CacheBehaviorTest— second lookup for the same country hits the cache, not the DB (assert via a query counter).
Integration Tests¶
ForwardCompatMultiCountryTest— inserts anSNcountry_profilerow + registers a stubSmsProviderbean withproviderId()returningletstalk_sms_snin test context; assertsNotificationService.dispatchfor an SN tenant routes to the new adapter without any NOTIF source change (AC-001d.2 forward-compat verification).MissingCountryProfileTest— tenant withcountry_code='SN', nocountry_profile.SNrow →POST /dispatchreturns 400tenant_country_unsupported.
What QA Will Validate¶
- The only place
letstalk_sms_ciappears in source is the adapter'sproviderId()return + thecountry_profile.CIseed row. No string literal inNotificationService/SmsDispatcher. - Adding a country in V1 will be a doc-only PR (review checklist item).
Out of Scope¶
- The actual
country_profiletable schema and the CI seed row — TENANT-owned (architecture §9.1 step 4). - WhatsApp provider adapter — NOTIF-003.
- Per-tenant provider account override (V2 BYO-provider per V1 PRD §A.5).
- Provider-credentials KMS — v0.1.0 (architecture §7 row "Secrets").
Definition of Done¶
- [ ]
CountryProfileResolverimplemented + cached via the TENANT-shared cache - [ ]
SmsProviderRegistry(and stubWaProviderRegistryfor NOTIF-003 to fill in) wired - [ ]
SmsProviderinterface carries aString providerId()discriminator method - [ ] AC-001d.1 and AC-001d.2 pass (including the forward-compat multi-country test)
- [ ] No string literal
"letstalk_sms_ci"outside the adapter class and the seed migration (grep-asserted in CI) - [ ] Both V1 DB profiles tested
- [ ]
country_profilereference data correctly NOT scoped bytenant_id(it is global per architecture §4) - [ ] No silent failures: unknown
provider_idat registry lookup throws a typed exception with a clear error log +correlation_id