From f37bbb6be5e6eda9dbfca8388639d3edad589f14 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 22 Jun 2026 10:08:15 +0200 Subject: [PATCH 01/32] Create BrpClient, query and response structs --- src/app/common/mod.rs | 27 ++++++++++ src/structs/common/brp.rs | 104 ++++++++++++++++++++++++++++++++++++++ src/structs/common/mod.rs | 2 + 3 files changed, 133 insertions(+) create mode 100644 src/app/common/mod.rs create mode 100644 src/structs/common/brp.rs diff --git a/src/app/common/mod.rs b/src/app/common/mod.rs new file mode 100644 index 000000000..4d0b762de --- /dev/null +++ b/src/app/common/mod.rs @@ -0,0 +1,27 @@ +//! Shared domain primitives and common pages. +//! +//! Scope: reusable value types (names, addresses, dates), shared extractors, +//! and common pages such as index and language switching. +mod extractors; +mod forms; +mod pages; +mod structs; + +pub use forms::{ + DutchAddressForm, FullNameForm, InternationalAddressForm, MinimalNameForm, SelectElectionForm, + SwitchElectionForm, +}; +pub use structs::{ + Address, BSN_NONE_CONFIRMATION, BrpVerification, Bsn, BsnOrNoneConfirmed, COUNTRY_CODES, + CountryCode, DateOfBirth, DisplayName, DutchAddress, EmptyAddressProblems, FirstName, + FormAction, FullName, Gender, HasSeverity, HouseNumber, HouseNumberAddition, InfoProblems, + Initials, InternationalAddress, InternationalPostalCode, LastName, LastNamePrefix, LegalName, + Locality, PlaceOfResidence, PostalCode, PotentialProblems, PreviousElectionResults, + Problematic, Problems, Severity, StateOrProvince, StreetName, UtcDateTime, WithProblems, +}; + +pub use pages::{ + HideDownloadWarningPath, IndexPath, LoginStartPath, LogoutPath, SelectElectionPath, + SwitchElectionPath, SwitchLanguagePath, auth_failure_response, not_found, public_router, + router, session_only_router, wellknown_router, +}; diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs new file mode 100644 index 000000000..db50e528c --- /dev/null +++ b/src/structs/common/brp.rs @@ -0,0 +1,104 @@ +use reqwest::{Client, Error}; +use serde::{Deserialize, Serialize}; + +use crate::{AppError, common::Bsn, persons::Person}; + +pub struct BrpClient { + http_client: Client, + base_url: String, + api_key: String, // Assuming an API key or Bearer token is needed for the real environment +} + +impl BrpClient { + pub fn new(base_url: &str, api_key: &str) -> Self { + Self { + http_client: Client::new(), + base_url: base_url.to_string(), + api_key: api_key.to_string(), + } + } + + /// Zoek personen endpoint (POST /personen) + pub async fn get_persons(&self, query: &BrpQuery) -> Result { + let url = format!("{}/personen", self.base_url); + + let response = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(query) + .send() + .await?; + + // Check for HTTP errors (400, 401, 403, etc.) + let response = response.error_for_status()?; + + dbg!(&response); + + // Parse the JSON response + // let parsed_response = response.json::().await?; + let parsed_response = BrpResponse::RaadpleegMetBurgerservicenummerResponse { + personen: vec![BrpPerson { + name: "Stefan".to_string(), + }], + }; + Ok(parsed_response) + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +pub enum BrpQuery { + ConsultWithBsn { + burgerservicenummer: Vec, + fields: Vec, + }, + // SearchWithLastNameAndDateOfBirth, + // SearchWithLastNameAndRegisteredMunicipality, + // SearchWithPostalCodeAndHouseNumber, + // SearchWithStreetHouseNumberAndRegisteredMunicipality, + // // TODO: translate this better if we ever end up using it. + // // Seems to be related to identifying a residence + // SearchWithNumberingIdentification, +} + +#[derive(Debug, Deserialize)] +struct BrpPerson { + name: String, +} + +// Misschien beter equality? +impl TryFrom for Person { + type Error = AppError; + + fn try_from(value: BrpPerson) -> Result { + Err(AppError::InternalServerError) + } +} + +pub trait BrpVerification { + // should become AppError + // async fn verify(&self) -> Result; + fn verify(&self) -> impl std::future::Future> + Send; +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum BrpResponse { + RaadpleegMetBurgerservicenummerResponse { personen: Vec }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_name() { + let brp_client = BrpClient::new("http://localhost:5010", ""); + let query = BrpQuery::ConsultWithBsn { + burgerservicenummer: vec!["100600505".parse().unwrap()], + fields: vec!["naam".to_string()], + }; + brp_client.get_persons(&query).await; + } +} diff --git a/src/structs/common/mod.rs b/src/structs/common/mod.rs index 9647533af..879291824 100644 --- a/src/structs/common/mod.rs +++ b/src/structs/common/mod.rs @@ -1,4 +1,5 @@ mod address; +mod brp; mod bsn; mod constrained_string; mod countries; @@ -22,6 +23,7 @@ mod severity; mod utc_date_time; pub use address::{Address, DutchAddress, InternationalAddress}; +pub use brp::BrpVerification; pub use bsn::{BSN_NONE_CONFIRMATION, Bsn, BsnOrNoneConfirmed}; pub use constrained_string::{FirstName, LegalName, StateOrProvince, StreetName}; pub use countries::COUNTRY_CODES; From b99fca559e3b77b715baab723d18d84d0c3178c9 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 22 Jun 2026 13:15:02 +0200 Subject: [PATCH 02/32] Add correct path to brp client --- src/structs/common/brp.rs | 52 ++++++++++--------- src/structs/persons/personal_data.rs | 74 +++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 25 deletions(-) diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index db50e528c..9ac432ad9 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -1,26 +1,32 @@ -use reqwest::{Client, Error}; +use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::{AppError, common::Bsn, persons::Person}; +use crate::{ + AppError, + common::{Bsn, DutchAddress, FullName}, + persons::{Person, PersonalData}, +}; pub struct BrpClient { http_client: Client, base_url: String, - api_key: String, // Assuming an API key or Bearer token is needed for the real environment + api_key: String, + persons_endpoint: String, } impl BrpClient { - pub fn new(base_url: &str, api_key: &str) -> Self { + pub fn new(base_url: &str, api_key: &str, persons_endpoint: &str) -> Self { Self { http_client: Client::new(), base_url: base_url.to_string(), api_key: api_key.to_string(), + persons_endpoint: persons_endpoint.to_string(), } } /// Zoek personen endpoint (POST /personen) - pub async fn get_persons(&self, query: &BrpQuery) -> Result { - let url = format!("{}/personen", self.base_url); + pub async fn get_persons(&self, query: &BrpQuery) -> Result { + let url = format!("{}/{}", self.base_url, self.persons_endpoint); let response = self .http_client @@ -35,13 +41,6 @@ impl BrpClient { dbg!(&response); - // Parse the JSON response - // let parsed_response = response.json::().await?; - let parsed_response = BrpResponse::RaadpleegMetBurgerservicenummerResponse { - personen: vec![BrpPerson { - name: "Stefan".to_string(), - }], - }; Ok(parsed_response) } } @@ -49,6 +48,7 @@ impl BrpClient { #[derive(Debug, Serialize)] #[serde(tag = "type")] pub enum BrpQuery { + #[serde(rename = "RaadpleegMetBurgerservicenummer")] ConsultWithBsn { burgerservicenummer: Vec, fields: Vec, @@ -62,9 +62,18 @@ pub enum BrpQuery { // SearchWithNumberingIdentification, } +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum BrpResponse { + #[serde(rename = "RaadpleegMetBurgerservicenummer")] + ConsultWithBsn { personen: Vec }, +} + #[derive(Debug, Deserialize)] struct BrpPerson { - name: String, + name: FullName, + personal_data: PersonalData, + address: DutchAddress, } // Misschien beter equality? @@ -82,23 +91,20 @@ pub trait BrpVerification { fn verify(&self) -> impl std::future::Future> + Send; } -#[derive(Debug, Deserialize)] -#[serde(tag = "type")] -pub enum BrpResponse { - RaadpleegMetBurgerservicenummerResponse { personen: Vec }, -} - #[cfg(test)] mod tests { use super::*; #[tokio::test] - async fn test_name() { - let brp_client = BrpClient::new("http://localhost:5010", ""); + async fn brp_request() { + let brp_client = + BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); let query = BrpQuery::ConsultWithBsn { burgerservicenummer: vec!["100600505".parse().unwrap()], fields: vec!["naam".to_string()], }; - brp_client.get_persons(&query).await; + + let response = brp_client.get_persons(&query).await.unwrap(); + println!("{:?}", response); } } diff --git a/src/structs/persons/personal_data.rs b/src/structs/persons/personal_data.rs index e6428680a..46f449522 100644 --- a/src/structs/persons/personal_data.rs +++ b/src/structs/persons/personal_data.rs @@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize}; use crate::{ ElectionConfig, OptionAsStrExt, common::{ - BsnOrNoneConfirmed, CountryCode, DateOfBirth, Gender, InfoProblems, PlaceOfResidence, - PotentialProblems, Problematic, Problems, + BrpVerification, BsnOrNoneConfirmed, CountryCode, DateOfBirth, Gender, InfoProblems, + PlaceOfResidence, PotentialProblems, Problematic, Problems, }, }; @@ -19,6 +19,61 @@ pub struct PersonalData { pub country: Option, } +impl BrpVerification for PersonalData { + async fn verify(&self) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post("http://localhost:5010/haalcentraal/api/brp/personen") + .header("Content-Type", "application/json") + .json( + r#"{ + "type": "RaadpleegMetBurgerservicenummer", + "burgerservicenummer": ["100600505"], + "fields": [ + "burgerservicenummer", + "datumInschrijvingInGemeente", + "geboorte.datum", + "gemeenteVanInschrijving", + "geslacht", + "naam.adellijkeTitelPredicaat", + "naam.geslachtsnaam", + "naam.voornamen", + "naam.voorvoegsel", + "naam.aanduidingNaamgebruik", + "nationaliteiten.datumIngangGeldigheid", + "nationaliteiten.nationaliteit", + "overlijden.datum", + "partners.aangaanHuwelijkPartnerschap.datum", + "partners.naam.geslachtsnaam", + "partners.naam.voorvoegsel", + "partners.ontbindingHuwelijkPartnerschap", + "uitsluitingKiesrecht", + "verblijfplaats.verblijfadres.huisletter", + "verblijfplaats.verblijfadres.huisnummer", + "verblijfplaats.verblijfadres.huisnummertoevoeging", + "verblijfplaats.verblijfadres.officieleStraatnaam", + "verblijfplaats.verblijfadres.postcode", + "verblijfplaats.verblijfadres.woonplaats" + ] + }"#, + ) + .send() + .await + .map_err(|_| String::from("Request failed"))?; + + // let status = resp.status(); + let text = resp + .text() + .await + .map_err(|_| String::from("Malformed response body"))?; + + dbg!(&text); + + let x = vec![1, 2]; + Ok(true) + } +} + impl Problematic for PersonalData { fn get_problems(&self, election: ElectionConfig) -> Problems { let mut potential_problems = Vec::new(); @@ -205,4 +260,19 @@ mod tests { .contains(&PotentialProblems::NoCountryOfResidence) ); } + + #[tokio::test] + async fn brp() -> Result<(), String> { + let data = PersonalData { + // First person from the brp personen mock with a PlaceOfResidence + gender: None, + bsn: Some("100600505".parse().unwrap()), + date_of_birth: Some("06-04-1975".parse().unwrap()), + place_of_residence: Some("'s-Gravenhage".parse().unwrap()), + country: Some("NL".parse().unwrap()), + }; + let result = data.verify().await?; + println!("{result}"); + panic!(); + } } From 4a60acfa281ca75e46ce02af36da329302e52bc2 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 22 Jun 2026 15:05:36 +0200 Subject: [PATCH 03/32] Brp deserializing (WIP) --- src/app/common/mod.rs | 12 +-- src/structs/common/brp.rs | 154 ++++++++++++++++++++++++--- src/structs/common/mod.rs | 1 - src/structs/persons/personal_data.rs | 74 +------------ 4 files changed, 149 insertions(+), 92 deletions(-) diff --git a/src/app/common/mod.rs b/src/app/common/mod.rs index 4d0b762de..025c2b689 100644 --- a/src/app/common/mod.rs +++ b/src/app/common/mod.rs @@ -12,12 +12,12 @@ pub use forms::{ SwitchElectionForm, }; pub use structs::{ - Address, BSN_NONE_CONFIRMATION, BrpVerification, Bsn, BsnOrNoneConfirmed, COUNTRY_CODES, - CountryCode, DateOfBirth, DisplayName, DutchAddress, EmptyAddressProblems, FirstName, - FormAction, FullName, Gender, HasSeverity, HouseNumber, HouseNumberAddition, InfoProblems, - Initials, InternationalAddress, InternationalPostalCode, LastName, LastNamePrefix, LegalName, - Locality, PlaceOfResidence, PostalCode, PotentialProblems, PreviousElectionResults, - Problematic, Problems, Severity, StateOrProvince, StreetName, UtcDateTime, WithProblems, + Address, BSN_NONE_CONFIRMATION, Bsn, BsnOrNoneConfirmed, COUNTRY_CODES, CountryCode, + DateOfBirth, DisplayName, DutchAddress, EmptyAddressProblems, FirstName, FormAction, FullName, + Gender, HasSeverity, HouseNumber, HouseNumberAddition, InfoProblems, Initials, + InternationalAddress, InternationalPostalCode, LastName, LastNamePrefix, LegalName, Locality, + PlaceOfResidence, PostalCode, PotentialProblems, PreviousElectionResults, Problematic, + Problems, Severity, StateOrProvince, StreetName, UtcDateTime, WithProblems, }; pub use pages::{ diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index 9ac432ad9..18578753c 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -1,9 +1,12 @@ +use chrono::NaiveDate; use reqwest::Client; use serde::{Deserialize, Serialize}; use crate::{ AppError, - common::{Bsn, DutchAddress, FullName}, + common::{ + Bsn, BsnOrNoneConfirmed, CountryCode, DateOfBirth, DutchAddress, FullName, PlaceOfResidence, + }, persons::{Person, PersonalData}, }; @@ -36,10 +39,7 @@ impl BrpClient { .send() .await?; - // Check for HTTP errors (400, 401, 403, etc.) - let response = response.error_for_status()?; - - dbg!(&response); + let parsed_response = response.json::().await?; Ok(parsed_response) } @@ -66,31 +66,159 @@ pub enum BrpQuery { #[serde(tag = "type")] pub enum BrpResponse { #[serde(rename = "RaadpleegMetBurgerservicenummer")] - ConsultWithBsn { personen: Vec }, + ConsultWithBsn { + #[serde(rename = "personen")] + persons: Vec, + }, +} + +// --- Intermediate deserialization structs for the BRP JSON format --- +#[derive(Deserialize)] +struct BrpGender { + gender: String, +} + +#[derive(Deserialize)] +struct BrpDatum { + datum: Option, +} + +#[derive(Deserialize)] +struct BrpNaam { + voornamen: Option, + geslachtsnaam: Option, + voorvoegsel: Option, + voorletters: Option, +} + +#[derive(Deserialize)] +struct BrpGeboorte { + datum: Option, +} + +#[derive(Deserialize)] +struct BrpVerblijfadres { + #[serde(rename = "korteStraatnaam")] + korte_straatnaam: Option, + huisnummer: Option, + huisnummertoevoeging: Option, + postcode: Option, + woonplaats: Option, +} + +#[derive(Deserialize)] +#[serde(tag = "type")] +enum BrpVerblijfplaats { + Adres { + verblijfadres: Option, + }, + VerblijfplaatsBuitenland { + verblijfadres: Option, + }, + #[serde(other)] + Other, } +#[derive(Deserialize)] +struct BrpPersonRaw { + burgerservicenummer: Option, + geslacht: Option, + naam: Option, + geboorte: Option, + verblijfplaats: Option, +} + +// --- BrpPerson with custom deserialization via BrpPersonRaw --- + #[derive(Debug, Deserialize)] +#[serde(try_from = "BrpPersonRaw")] struct BrpPerson { name: FullName, personal_data: PersonalData, address: DutchAddress, } +impl From for BrpPerson { + fn from(raw: BrpPersonRaw) -> Self { + let name = raw + .naam + .map(|naam| FullName { + first_name: naam.voornamen.and_then(|s| s.parse().ok()), + last_name: naam + .geslachtsnaam + .and_then(|s| s.parse().ok()) + .unwrap_or_default(), + last_name_prefix: naam.voorvoegsel.and_then(|s| s.parse().ok()), + initials: naam + .voorletters + .and_then(|s| s.parse().ok()) + .unwrap_or_default(), + }) + .unwrap_or_default(); + + let bsn = raw + .burgerservicenummer + .and_then(|s| s.parse::().ok()) + .map(BsnOrNoneConfirmed::Bsn); + + let gender = raw.geslacht.and_then(|g| g.gender.parse().ok()); + + let date_of_birth = raw + .geboorte + .as_ref() + .and_then(|g| g.datum.as_ref()) + .and_then(|d| d.datum.as_ref()) + .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) + .map(DateOfBirth::from); + + let (place_of_residence, country, address) = match raw.verblijfplaats { + Some(BrpVerblijfplaats::Adres { verblijfadres }) => { + let country: Option = "NL".parse().ok(); + let (por, addr) = verblijfadres + .map(|va| { + let por: Option = + va.woonplaats.as_deref().and_then(|s| s.parse().ok()); + let addr = DutchAddress { + street_name: va.korte_straatnaam.and_then(|s| s.parse().ok()), + house_number: va.huisnummer.and_then(|n| n.to_string().parse().ok()), + house_number_addition: va + .huisnummertoevoeging + .and_then(|s| s.parse().ok()), + locality: va.woonplaats.and_then(|s| s.parse().ok()), + postal_code: va.postcode.and_then(|s| s.parse().ok()), + known_in_bag: None, + }; + (por, addr) + }) + .unzip(); + (por.flatten(), country, addr.unwrap_or_default()) + } + _ => (None, None, DutchAddress::default()), + }; + + BrpPerson { + name, + personal_data: PersonalData { + gender, + bsn, + date_of_birth, + place_of_residence, + country, + }, + address, + } + } +} + // Misschien beter equality? impl TryFrom for Person { type Error = AppError; - fn try_from(value: BrpPerson) -> Result { + fn try_from(_value: BrpPerson) -> Result { Err(AppError::InternalServerError) } } -pub trait BrpVerification { - // should become AppError - // async fn verify(&self) -> Result; - fn verify(&self) -> impl std::future::Future> + Send; -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/structs/common/mod.rs b/src/structs/common/mod.rs index 879291824..19d879fd3 100644 --- a/src/structs/common/mod.rs +++ b/src/structs/common/mod.rs @@ -23,7 +23,6 @@ mod severity; mod utc_date_time; pub use address::{Address, DutchAddress, InternationalAddress}; -pub use brp::BrpVerification; pub use bsn::{BSN_NONE_CONFIRMATION, Bsn, BsnOrNoneConfirmed}; pub use constrained_string::{FirstName, LegalName, StateOrProvince, StreetName}; pub use countries::COUNTRY_CODES; diff --git a/src/structs/persons/personal_data.rs b/src/structs/persons/personal_data.rs index 46f449522..e6428680a 100644 --- a/src/structs/persons/personal_data.rs +++ b/src/structs/persons/personal_data.rs @@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize}; use crate::{ ElectionConfig, OptionAsStrExt, common::{ - BrpVerification, BsnOrNoneConfirmed, CountryCode, DateOfBirth, Gender, InfoProblems, - PlaceOfResidence, PotentialProblems, Problematic, Problems, + BsnOrNoneConfirmed, CountryCode, DateOfBirth, Gender, InfoProblems, PlaceOfResidence, + PotentialProblems, Problematic, Problems, }, }; @@ -19,61 +19,6 @@ pub struct PersonalData { pub country: Option, } -impl BrpVerification for PersonalData { - async fn verify(&self) -> Result { - let client = reqwest::Client::new(); - let resp = client - .post("http://localhost:5010/haalcentraal/api/brp/personen") - .header("Content-Type", "application/json") - .json( - r#"{ - "type": "RaadpleegMetBurgerservicenummer", - "burgerservicenummer": ["100600505"], - "fields": [ - "burgerservicenummer", - "datumInschrijvingInGemeente", - "geboorte.datum", - "gemeenteVanInschrijving", - "geslacht", - "naam.adellijkeTitelPredicaat", - "naam.geslachtsnaam", - "naam.voornamen", - "naam.voorvoegsel", - "naam.aanduidingNaamgebruik", - "nationaliteiten.datumIngangGeldigheid", - "nationaliteiten.nationaliteit", - "overlijden.datum", - "partners.aangaanHuwelijkPartnerschap.datum", - "partners.naam.geslachtsnaam", - "partners.naam.voorvoegsel", - "partners.ontbindingHuwelijkPartnerschap", - "uitsluitingKiesrecht", - "verblijfplaats.verblijfadres.huisletter", - "verblijfplaats.verblijfadres.huisnummer", - "verblijfplaats.verblijfadres.huisnummertoevoeging", - "verblijfplaats.verblijfadres.officieleStraatnaam", - "verblijfplaats.verblijfadres.postcode", - "verblijfplaats.verblijfadres.woonplaats" - ] - }"#, - ) - .send() - .await - .map_err(|_| String::from("Request failed"))?; - - // let status = resp.status(); - let text = resp - .text() - .await - .map_err(|_| String::from("Malformed response body"))?; - - dbg!(&text); - - let x = vec![1, 2]; - Ok(true) - } -} - impl Problematic for PersonalData { fn get_problems(&self, election: ElectionConfig) -> Problems { let mut potential_problems = Vec::new(); @@ -260,19 +205,4 @@ mod tests { .contains(&PotentialProblems::NoCountryOfResidence) ); } - - #[tokio::test] - async fn brp() -> Result<(), String> { - let data = PersonalData { - // First person from the brp personen mock with a PlaceOfResidence - gender: None, - bsn: Some("100600505".parse().unwrap()), - date_of_birth: Some("06-04-1975".parse().unwrap()), - place_of_residence: Some("'s-Gravenhage".parse().unwrap()), - country: Some("NL".parse().unwrap()), - }; - let result = data.verify().await?; - println!("{result}"); - panic!(); - } } From 6e6643331c69c3a34990cc6fbe77213732b8ed3e Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 22 Jun 2026 17:00:06 +0200 Subject: [PATCH 04/32] Add verify function to brp client --- src/structs/common/brp.rs | 84 ++++++++++++++++++++++++++++++++------- src/utils/test_utils.rs | 29 ++++++++++++++ 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index 18578753c..548c477a2 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -27,8 +27,7 @@ impl BrpClient { } } - /// Zoek personen endpoint (POST /personen) - pub async fn get_persons(&self, query: &BrpQuery) -> Result { + pub async fn get_persons(&self, query: &BrpQuery) -> Result, AppError> { let url = format!("{}/{}", self.base_url, self.persons_endpoint); let response = self @@ -39,9 +38,57 @@ impl BrpClient { .send() .await?; - let parsed_response = response.json::().await?; + match response.json::().await? { + BrpResponse::ConsultWithBsn { persons } => Ok(persons), + } + } + + async fn verify(&self, person: &Person) -> Result { + let query = match person.personal_data.bsn { + Some(BsnOrNoneConfirmed::Bsn(ref bsn)) => BrpQuery::ConsultWithBsn { + burgerservicenummer: vec![bsn.clone()], + fields: vec![ + "burgerservicenummer".to_string(), + "geboorte".to_string(), + "geslacht".to_string(), + "naam".to_string(), + "verblijfplaats".to_string(), + ], + }, + Some(BsnOrNoneConfirmed::NoneConfirmed) => { + unimplemented!("BRP search with address? Or manual verification") + } + None => { + unimplemented!( + "Return error, because this person should have a BSN (or none confirmed)?" + ) + } + }; + + let brp_persons = self.get_persons(&query).await?; + let brp_person = match brp_persons.as_slice() { + [] => todo!("Handle person not found"), + [brp_person] => brp_person, + [..] => todo!("Handle person not unique"), + }; - Ok(parsed_response) + Ok([ + // Check all, except `known_in_bag` + person.address.street_name == brp_person.address.street_name, + person.address.house_number == brp_person.address.house_number, + person.address.house_number_addition == brp_person.address.house_number_addition, + person.address.locality == brp_person.address.locality, + person.address.postal_code == brp_person.address.postal_code, + // Don't check First name (roepnaam) + person.name.last_name == brp_person.name.last_name, + person.name.last_name_prefix == brp_person.name.last_name_prefix, + person.name.initials == brp_person.name.initials, + // Check all fields of personal_data + brp_person.personal_data == person.personal_data, + ] + .iter() + .inspect(|f| println!("{f:?}")) + .all(|&b| b)) } } @@ -75,6 +122,7 @@ pub enum BrpResponse { // --- Intermediate deserialization structs for the BRP JSON format --- #[derive(Deserialize)] struct BrpGender { + #[serde(rename = "code")] gender: String, } @@ -128,8 +176,6 @@ struct BrpPersonRaw { verblijfplaats: Option, } -// --- BrpPerson with custom deserialization via BrpPersonRaw --- - #[derive(Debug, Deserialize)] #[serde(try_from = "BrpPersonRaw")] struct BrpPerson { @@ -179,6 +225,7 @@ impl From for BrpPerson { let por: Option = va.woonplaats.as_deref().and_then(|s| s.parse().ok()); let addr = DutchAddress { + // TODO: Confirm that this should be korte_straatnaam street_name: va.korte_straatnaam.and_then(|s| s.parse().ok()), house_number: va.huisnummer.and_then(|n| n.to_string().parse().ok()), house_number_addition: va @@ -210,17 +257,10 @@ impl From for BrpPerson { } } -// Misschien beter equality? -impl TryFrom for Person { - type Error = AppError; - - fn try_from(_value: BrpPerson) -> Result { - Err(AppError::InternalServerError) - } -} - #[cfg(test)] mod tests { + use crate::test_utils::sample_person_from_brp; + use super::*; #[tokio::test] @@ -235,4 +275,18 @@ mod tests { let response = brp_client.get_persons(&query).await.unwrap(); println!("{:?}", response); } + + #[tokio::test] + async fn brp_verify() { + let brp_client = + BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + + let person = sample_person_from_brp(); + + match brp_client.verify(&person).await { + Err(e) => panic!("brp verification error: {}", e.to_string()), + Ok(false) => panic!("Valid BRP person should verify"), + _ => {} + } + } } diff --git a/src/utils/test_utils.rs b/src/utils/test_utils.rs index ea4da436e..fbb0cbdde 100644 --- a/src/utils/test_utils.rs +++ b/src/utils/test_utils.rs @@ -158,6 +158,35 @@ pub fn sample_person(id: PersonId) -> Person { } } +pub fn sample_person_from_brp() -> Person { + Person { + id: PersonId::new(), + name: FullName { + first_name: Some("Tina-Antïna".parse().unwrap()), + last_name: "Bruin".parse().unwrap(), + last_name_prefix: Some("de".parse().unwrap()), + initials: "T.".parse().unwrap(), + }, + personal_data: PersonalData { + gender: Some(Gender::Female), + bsn: Some("900194054".parse().unwrap()), + date_of_birth: Some("11-12-1990".parse().unwrap()), + place_of_residence: Some("Utrecht".parse().unwrap()), + country: Some("NL".parse().unwrap()), + }, + address: DutchAddress { + street_name: Some("Croeselaan".parse().unwrap()), + house_number: Some("15".parse().unwrap()), + house_number_addition: None, + locality: Some("Utrecht".parse().unwrap()), + postal_code: Some("3521BJ".parse().unwrap()), + known_in_bag: None, + }, + representative: None, + updated_at: Default::default(), + } +} + pub fn sample_person_with_last_name(id: PersonId, last_name: &str) -> Person { sample_person_with(id, None, last_name, None, "H.A.H.A.") } From b7803a220e000ca15722be7dfb8a10a2367b25d5 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Thu, 25 Jun 2026 13:44:42 +0200 Subject: [PATCH 05/32] Add brp mock to docker compose --- compose.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/compose.yml b/compose.yml index 6090b2505..8643682ee 100644 --- a/compose.yml +++ b/compose.yml @@ -9,3 +9,12 @@ services: TZ: Europe/Amsterdam ports: ['127.0.0.1:5432:5432'] networks: [default] + + personen-mock: + container_name: brp-personen-mock + image: ghcr.io/brp-api/personen-mock:2.7.0-latest + environment: + - ASPNETCORE_ENVIRONMENT=Release + - ASPNETCORE_URLS=http://+:5010 + ports: + - "5010:5010" From ab2a9222e2cafc7020a095eccb6def9f057d6ba3 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Thu, 25 Jun 2026 15:30:30 +0200 Subject: [PATCH 06/32] Translate struct name and fields to English --- src/structs/common/brp.rs | 146 ++++++++++++++++++++++---------------- 1 file changed, 84 insertions(+), 62 deletions(-) diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index 548c477a2..6a0ca0faa 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -1,6 +1,6 @@ use chrono::NaiveDate; use reqwest::Client; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ AppError, @@ -46,8 +46,9 @@ impl BrpClient { async fn verify(&self, person: &Person) -> Result { let query = match person.personal_data.bsn { Some(BsnOrNoneConfirmed::Bsn(ref bsn)) => BrpQuery::ConsultWithBsn { - burgerservicenummer: vec![bsn.clone()], + bsn: vec![bsn.clone()], fields: vec![ + // TODO: Create a Field type? "burgerservicenummer".to_string(), "geboorte".to_string(), "geslacht".to_string(), @@ -97,16 +98,10 @@ impl BrpClient { pub enum BrpQuery { #[serde(rename = "RaadpleegMetBurgerservicenummer")] ConsultWithBsn { - burgerservicenummer: Vec, + #[serde(rename = "burgerservicenummer")] + bsn: Vec, fields: Vec, }, - // SearchWithLastNameAndDateOfBirth, - // SearchWithLastNameAndRegisteredMunicipality, - // SearchWithPostalCodeAndHouseNumber, - // SearchWithStreetHouseNumberAndRegisteredMunicipality, - // // TODO: translate this better if we ever end up using it. - // // Seems to be related to identifying a residence - // SearchWithNumberingIdentification, } #[derive(Debug, Deserialize)] @@ -127,41 +122,58 @@ struct BrpGender { } #[derive(Deserialize)] -struct BrpDatum { - datum: Option, +struct BrpDate { + #[serde(rename = "datum")] + date: Option, } #[derive(Deserialize)] -struct BrpNaam { - voornamen: Option, - geslachtsnaam: Option, - voorvoegsel: Option, - voorletters: Option, +struct BrpName { + #[serde(rename = "voornamen")] + first_names: Option, + #[serde(rename = "geslachtsnaam")] + last_name: Option, + #[serde(rename = "voorvoegsel")] + last_name_prefix: Option, + #[serde(rename = "voorletters")] + initials: Option, } #[derive(Deserialize)] -struct BrpGeboorte { - datum: Option, +struct BrpBirth { + #[serde(rename = "datum")] + date: Option, } #[derive(Deserialize)] -struct BrpVerblijfadres { - #[serde(rename = "korteStraatnaam")] - korte_straatnaam: Option, - huisnummer: Option, - huisnummertoevoeging: Option, - postcode: Option, - woonplaats: Option, +struct BrpAddress { + // TODO: Confirm that this should be officieleStraatnaam + // Or handle this by checking if either matches? If this is only used as a correspondence address, + // than that should be sufficient + #[serde(rename = "officieleStraatnaam")] + street_name: Option, + #[serde(rename = "huisnummer")] + house_number: Option, + #[serde(rename = "huisnummertoevoeging")] + house_number_addition: Option, + #[serde(rename = "postcode")] + postal_code: Option, + #[serde(rename = "woonplaats")] + place_of_residence: Option, } #[derive(Deserialize)] #[serde(tag = "type")] -enum BrpVerblijfplaats { - Adres { - verblijfadres: Option, +enum BrpPlaceOfResidence { + #[serde(rename = "Adres")] + Address { + #[serde(rename = "verblijfadres")] + residence_address: Option, }, - VerblijfplaatsBuitenland { - verblijfadres: Option, + #[serde(rename = "VerblijfPlaatsBuitenland")] + InternationalResidenceAddress { + #[serde(rename = "verblijfadres")] + residence_address: Option, }, #[serde(other)] Other, @@ -169,16 +181,21 @@ enum BrpVerblijfplaats { #[derive(Deserialize)] struct BrpPersonRaw { - burgerservicenummer: Option, - geslacht: Option, - naam: Option, - geboorte: Option, - verblijfplaats: Option, + #[serde(rename = "burgerservicenummer")] + bsn: Option, + #[serde(rename = "geslacht")] + gender: Option, + #[serde(rename = "naam")] + name: Option, + #[serde(rename = "geboorte")] + birth: Option, + #[serde(rename = "verblijfplaats")] + place_of_residence: Option, } #[derive(Debug, Deserialize)] -#[serde(try_from = "BrpPersonRaw")] -struct BrpPerson { +#[serde(from = "BrpPersonRaw")] +pub struct BrpPerson { name: FullName, personal_data: PersonalData, address: DutchAddress, @@ -187,52 +204,54 @@ struct BrpPerson { impl From for BrpPerson { fn from(raw: BrpPersonRaw) -> Self { let name = raw - .naam + .name .map(|naam| FullName { - first_name: naam.voornamen.and_then(|s| s.parse().ok()), + // First name isn't checked, because this does not necessarily need to be the same (roepnaam) + first_name: None, last_name: naam - .geslachtsnaam + .last_name .and_then(|s| s.parse().ok()) .unwrap_or_default(), - last_name_prefix: naam.voorvoegsel.and_then(|s| s.parse().ok()), + last_name_prefix: naam.last_name_prefix.and_then(|s| s.parse().ok()), initials: naam - .voorletters + .initials .and_then(|s| s.parse().ok()) .unwrap_or_default(), }) .unwrap_or_default(); let bsn = raw - .burgerservicenummer + .bsn .and_then(|s| s.parse::().ok()) .map(BsnOrNoneConfirmed::Bsn); - let gender = raw.geslacht.and_then(|g| g.gender.parse().ok()); + let gender = raw.gender.and_then(|g| g.gender.parse().ok()); let date_of_birth = raw - .geboorte + .birth .as_ref() - .and_then(|g| g.datum.as_ref()) - .and_then(|d| d.datum.as_ref()) + .and_then(|b| b.date.as_ref()) + .and_then(|d| d.date.as_ref()) .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) .map(DateOfBirth::from); - let (place_of_residence, country, address) = match raw.verblijfplaats { - Some(BrpVerblijfplaats::Adres { verblijfadres }) => { + let (place_of_residence, country, address) = match raw.place_of_residence { + Some(BrpPlaceOfResidence::Address { residence_address }) => { let country: Option = "NL".parse().ok(); - let (por, addr) = verblijfadres + let (por, addr) = residence_address .map(|va| { - let por: Option = - va.woonplaats.as_deref().and_then(|s| s.parse().ok()); + let por: Option = va + .place_of_residence + .as_deref() + .and_then(|s| s.parse().ok()); let addr = DutchAddress { - // TODO: Confirm that this should be korte_straatnaam - street_name: va.korte_straatnaam.and_then(|s| s.parse().ok()), - house_number: va.huisnummer.and_then(|n| n.to_string().parse().ok()), + street_name: va.street_name.and_then(|s| s.parse().ok()), + house_number: va.house_number.and_then(|n| n.to_string().parse().ok()), house_number_addition: va - .huisnummertoevoeging + .house_number_addition .and_then(|s| s.parse().ok()), - locality: va.woonplaats.and_then(|s| s.parse().ok()), - postal_code: va.postcode.and_then(|s| s.parse().ok()), + locality: va.place_of_residence.and_then(|s| s.parse().ok()), + postal_code: va.postal_code.and_then(|s| s.parse().ok()), known_in_bag: None, }; (por, addr) @@ -268,7 +287,7 @@ mod tests { let brp_client = BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); let query = BrpQuery::ConsultWithBsn { - burgerservicenummer: vec!["100600505".parse().unwrap()], + bsn: vec!["100600505".parse().unwrap()], fields: vec!["naam".to_string()], }; @@ -284,8 +303,11 @@ mod tests { let person = sample_person_from_brp(); match brp_client.verify(&person).await { - Err(e) => panic!("brp verification error: {}", e.to_string()), - Ok(false) => panic!("Valid BRP person should verify"), + Err(e) => panic!("brp verification error: {e}"), + Ok(false) => panic!( + "person could not be verified: {}", + serde_json::to_string_pretty(&person).unwrap() + ), _ => {} } } From ea3b8378ea3faeb53ef55854e10de75a4973a317 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Thu, 25 Jun 2026 17:03:35 +0200 Subject: [PATCH 07/32] improve handling of international addresses --- src/structs/common/brp.rs | 122 +++++++++++++++++++++----------------- 1 file changed, 68 insertions(+), 54 deletions(-) diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index 6a0ca0faa..9aa5fab7f 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -1,12 +1,10 @@ use chrono::NaiveDate; use reqwest::Client; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use crate::{ AppError, - common::{ - Bsn, BsnOrNoneConfirmed, CountryCode, DateOfBirth, DutchAddress, FullName, PlaceOfResidence, - }, + common::{Bsn, BsnOrNoneConfirmed, DateOfBirth, DutchAddress, FullName}, persons::{Person, PersonalData}, }; @@ -73,23 +71,33 @@ impl BrpClient { [..] => todo!("Handle person not unique"), }; - Ok([ - // Check all, except `known_in_bag` - person.address.street_name == brp_person.address.street_name, - person.address.house_number == brp_person.address.house_number, - person.address.house_number_addition == brp_person.address.house_number_addition, - person.address.locality == brp_person.address.locality, - person.address.postal_code == brp_person.address.postal_code, + let address_is_valid = match &brp_person.address { + Some(address) => { + // Check all, except `known_in_bag` + person.address.street_name == address.street_name + && person.address.house_number == address.house_number + && person.address.house_number_addition == address.house_number_addition + && person.address.locality == address.locality + && person.address.postal_code == address.postal_code + } + None => { + eprintln!( + "Not a Dutch Address or no address at all (because the field 'verblijfplaats' was not included)" + ); + true + } + }; + + Ok(address_is_valid && // Don't check First name (roepnaam) - person.name.last_name == brp_person.name.last_name, - person.name.last_name_prefix == brp_person.name.last_name_prefix, - person.name.initials == brp_person.name.initials, - // Check all fields of personal_data - brp_person.personal_data == person.personal_data, - ] - .iter() - .inspect(|f| println!("{f:?}")) - .all(|&b| b)) + person.name.last_name == brp_person.name.last_name && + person.name.last_name_prefix == brp_person.name.last_name_prefix && + person.name.initials == brp_person.name.initials && + // Check all fields of personal_data except country, check gender only when filled in + brp_person.personal_data.bsn == person.personal_data.bsn && + brp_person.personal_data.date_of_birth == person.personal_data.date_of_birth && + // Gender field is optional, but if it filled in, we check it + (brp_person.personal_data.gender == person.personal_data.gender || person.personal_data.gender.is_none())) } } @@ -149,7 +157,7 @@ struct BrpBirth { struct BrpAddress { // TODO: Confirm that this should be officieleStraatnaam // Or handle this by checking if either matches? If this is only used as a correspondence address, - // than that should be sufficient + // then that should be sufficient #[serde(rename = "officieleStraatnaam")] street_name: Option, #[serde(rename = "huisnummer")] @@ -168,15 +176,10 @@ enum BrpPlaceOfResidence { #[serde(rename = "Adres")] Address { #[serde(rename = "verblijfadres")] - residence_address: Option, - }, - #[serde(rename = "VerblijfPlaatsBuitenland")] - InternationalResidenceAddress { - #[serde(rename = "verblijfadres")] - residence_address: Option, + residence_address: BrpAddress, }, #[serde(other)] - Other, + NonDutchAddress, } #[derive(Deserialize)] @@ -198,7 +201,7 @@ struct BrpPersonRaw { pub struct BrpPerson { name: FullName, personal_data: PersonalData, - address: DutchAddress, + address: Option, } impl From for BrpPerson { @@ -235,31 +238,39 @@ impl From for BrpPerson { .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) .map(DateOfBirth::from); - let (place_of_residence, country, address) = match raw.place_of_residence { - Some(BrpPlaceOfResidence::Address { residence_address }) => { - let country: Option = "NL".parse().ok(); - let (por, addr) = residence_address - .map(|va| { - let por: Option = va - .place_of_residence - .as_deref() - .and_then(|s| s.parse().ok()); - let addr = DutchAddress { - street_name: va.street_name.and_then(|s| s.parse().ok()), - house_number: va.house_number.and_then(|n| n.to_string().parse().ok()), - house_number_addition: va - .house_number_addition - .and_then(|s| s.parse().ok()), - locality: va.place_of_residence.and_then(|s| s.parse().ok()), - postal_code: va.postal_code.and_then(|s| s.parse().ok()), - known_in_bag: None, - }; - (por, addr) - }) - .unzip(); - (por.flatten(), country, addr.unwrap_or_default()) + let (address, place_of_residence) = match raw.place_of_residence { + Some(BrpPlaceOfResidence::Address { + residence_address: ra, + }) => { + let addr = Some(DutchAddress { + street_name: ra.street_name.and_then(|s| s.parse().ok()), + house_number: ra.house_number.and_then(|s| s.to_string().parse().ok()), + house_number_addition: ra.house_number_addition.and_then(|s| s.parse().ok()), + locality: ra + .place_of_residence + .as_deref() + .and_then(|s| s.parse().ok()), + postal_code: ra.postal_code.and_then(|s| s.parse().ok()), + // Known in BRP probably implies known in bag, I guess maybe this could be Some(true), but + // I don't think it matters + known_in_bag: None, + }); + + // TODO: Is place of residence really the same as locality (above). + // (though note that above is parsed as `Locality`, and below as `PlaceOfResidence`) + let por = ra.place_of_residence.and_then(|s| s.parse().ok()); + + (addr, por) + } + Some(BrpPlaceOfResidence::NonDutchAddress) => { + // TODO: How to handle this? Set the address to None and conduct an additional BRP check + // for the Authorised Person? + todo!("Not a Dutch Address") + } + None => { + eprintln!("Field 'verblijfplaats' not included"); + (None, None) } - _ => (None, None, DutchAddress::default()), }; BrpPerson { @@ -269,7 +280,10 @@ impl From for BrpPerson { bsn, date_of_birth, place_of_residence, - country, + // TODO: Can country be None here? Because we check with the BRP whether the address is international. + // If it is, then `address` will be None (since we can't verify international addresses) and we know that + // instead, it is necesarry to verify the Authorised Person's address + country: None, }, address, } From dca72e9c0f6382b4f1d3eeea74930b0aadd32e29 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Thu, 2 Jul 2026 12:38:50 +0200 Subject: [PATCH 08/32] Use structstruck for nested BRP struct definitions --- Cargo.lock | 22 +++++++ Cargo.toml | 2 + src/structs/common/brp.rs | 135 +++++++++++++++++--------------------- 3 files changed, 86 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a62b9f75..e47fd1c06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -889,6 +889,7 @@ dependencies = [ "serde_urlencoded", "sha2 0.11.0", "sqlx", + "structstruck", "subtle", "textris-pdf", "tokio", @@ -3037,6 +3038,17 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "structstruck" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f318209842d04696a2c139cd3d5d4d75c4a9e2af435b077aa5cbd391a79c34b3" +dependencies = [ + "proc-macro2", + "quote", + "venial", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -3550,6 +3562,16 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "venial" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61584a325b16f97b5b25fcc852eb9550843a251057a5e3e5992d2376f3df4bb2" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 81ec0da7f..afad65dbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ aes-gcm = "0.11.0" # 118M downloads (RustCrypto) hkdf = "0.13.0" # 198M downloads (RustCrypto) postcard = { version = "1.1.3", default-features = false, features = ["alloc"] } # 42M downloads (James Munns) secrecy = "0.10.3" # 126M downloads (Tony Arcieri) +structstruck = "0.5.1" # 212K downloads (Julius Michaelis) urlencoding = "2.1.3" # 213M downloads (Kornel) zeroize = "1.9.0" # 359M downloads (RustCrypto) @@ -154,6 +155,7 @@ sqlx = { workspace = true, optional = true } subtle = { workspace = true } textris-pdf = { workspace = true } tokio = { workspace = true } +structstruck = { workspace = true } tokio-util = { workspace = true } tower-http = { workspace = true } tracing = { workspace = true } diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index 9aa5fab7f..23f35b016 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -123,79 +123,6 @@ pub enum BrpResponse { } // --- Intermediate deserialization structs for the BRP JSON format --- -#[derive(Deserialize)] -struct BrpGender { - #[serde(rename = "code")] - gender: String, -} - -#[derive(Deserialize)] -struct BrpDate { - #[serde(rename = "datum")] - date: Option, -} - -#[derive(Deserialize)] -struct BrpName { - #[serde(rename = "voornamen")] - first_names: Option, - #[serde(rename = "geslachtsnaam")] - last_name: Option, - #[serde(rename = "voorvoegsel")] - last_name_prefix: Option, - #[serde(rename = "voorletters")] - initials: Option, -} - -#[derive(Deserialize)] -struct BrpBirth { - #[serde(rename = "datum")] - date: Option, -} - -#[derive(Deserialize)] -struct BrpAddress { - // TODO: Confirm that this should be officieleStraatnaam - // Or handle this by checking if either matches? If this is only used as a correspondence address, - // then that should be sufficient - #[serde(rename = "officieleStraatnaam")] - street_name: Option, - #[serde(rename = "huisnummer")] - house_number: Option, - #[serde(rename = "huisnummertoevoeging")] - house_number_addition: Option, - #[serde(rename = "postcode")] - postal_code: Option, - #[serde(rename = "woonplaats")] - place_of_residence: Option, -} - -#[derive(Deserialize)] -#[serde(tag = "type")] -enum BrpPlaceOfResidence { - #[serde(rename = "Adres")] - Address { - #[serde(rename = "verblijfadres")] - residence_address: BrpAddress, - }, - #[serde(other)] - NonDutchAddress, -} - -#[derive(Deserialize)] -struct BrpPersonRaw { - #[serde(rename = "burgerservicenummer")] - bsn: Option, - #[serde(rename = "geslacht")] - gender: Option, - #[serde(rename = "naam")] - name: Option, - #[serde(rename = "geboorte")] - birth: Option, - #[serde(rename = "verblijfplaats")] - place_of_residence: Option, -} - #[derive(Debug, Deserialize)] #[serde(from = "BrpPersonRaw")] pub struct BrpPerson { @@ -204,6 +131,68 @@ pub struct BrpPerson { address: Option, } +structstruck::strike! { + #[structstruck::each[derive(Debug, Deserialize)]] + struct BrpPersonRaw { + #[serde(rename = "burgerservicenummer")] + bsn: Option, + #[serde(rename = "geslacht")] + gender: Option, + #[serde(rename = "naam")] + name: Option, + #[serde(rename = "geslachtsnaam")] + last_name: Option, + #[serde(rename = "voorvoegsel")] + last_name_prefix: Option, + #[serde(rename = "voorletters")] + initials: Option + }>, + #[serde(rename = "geboorte")] + birth: Option + }> + }>, + #[serde(rename = "woonplaats")] + place_of_residence: Option< + #[serde(tag = "type")] + enum BrpPlaceOfResidence { + #[serde(rename = "Adres")] + Address { + #[serde(rename = "verblijfplaats")] + residence_address: struct BrpAddress { + // TODO: Confirm that this should be officieleStraatnaam + // Or handle this by checking if either matches? If this is only used as a correspondence address, + // then that should be sufficient + #[serde(rename = "officieleStraatnaam")] + street_name: Option, + #[serde(rename = "huisnummer")] + house_number: Option, + #[serde(rename = "huisnummertoevoeging")] + house_number_addition: Option, + #[serde(rename = "postcode")] + postal_code: Option, + #[serde(rename = "woonplaats")] + place_of_residence: Option, + } + }, + #[serde(other)] + NonDutchAddress + }> + } +} + impl From for BrpPerson { fn from(raw: BrpPersonRaw) -> Self { let name = raw From 5c879a59497acc6dfe4f2f3aabdf42f7b1285c51 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Fri, 3 Jul 2026 09:24:03 +0200 Subject: [PATCH 09/32] Remove first name from BrpPersonRaw --- src/structs/common/brp.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index 23f35b016..d0fb04428 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -96,7 +96,7 @@ impl BrpClient { // Check all fields of personal_data except country, check gender only when filled in brp_person.personal_data.bsn == person.personal_data.bsn && brp_person.personal_data.date_of_birth == person.personal_data.date_of_birth && - // Gender field is optional, but if it filled in, we check it + // Gender field is optional, but if it is filled in, we check it (brp_person.personal_data.gender == person.personal_data.gender || person.personal_data.gender.is_none())) } } @@ -122,7 +122,6 @@ pub enum BrpResponse { }, } -// --- Intermediate deserialization structs for the BRP JSON format --- #[derive(Debug, Deserialize)] #[serde(from = "BrpPersonRaw")] pub struct BrpPerson { @@ -147,8 +146,6 @@ structstruck::strike! { }>, #[serde(rename = "naam")] name: Option, #[serde(rename = "geslachtsnaam")] last_name: Option, #[serde(rename = "voorvoegsel")] From 99bf14d0fa4f34dd1dc19e38b40a10667c65d632 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Fri, 3 Jul 2026 10:58:20 +0200 Subject: [PATCH 10/32] Add basic brp check when csb importing political group --- src/app/common/mod.rs | 2 +- src/csb/import/pages/import.rs | 23 +++++++++++++++++++++-- src/csb/store_csb/getters.rs | 6 ++++++ src/structs/common/brp.rs | 6 +----- src/structs/common/mod.rs | 1 + 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/app/common/mod.rs b/src/app/common/mod.rs index 025c2b689..c5f856429 100644 --- a/src/app/common/mod.rs +++ b/src/app/common/mod.rs @@ -12,7 +12,7 @@ pub use forms::{ SwitchElectionForm, }; pub use structs::{ - Address, BSN_NONE_CONFIRMATION, Bsn, BsnOrNoneConfirmed, COUNTRY_CODES, CountryCode, + Address, BSN_NONE_CONFIRMATION, BrpClient, Bsn, BsnOrNoneConfirmed, COUNTRY_CODES, CountryCode, DateOfBirth, DisplayName, DutchAddress, EmptyAddressProblems, FirstName, FormAction, FullName, Gender, HasSeverity, HouseNumber, HouseNumberAddition, InfoProblems, Initials, InternationalAddress, InternationalPostalCode, LastName, LastNamePrefix, LegalName, Locality, diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index ca82283bc..a813cdd34 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use askama::Template; use axum::{ extract::State, @@ -6,8 +8,9 @@ use axum::{ use serde::Deserialize; use crate::{ - AppError, AppState, Context, CsbContext, CsbEvent, Form, HtmlTemplate, Locale, PgStoreData, - StreamId, csb::examination::CsbExaminationOverviewPath, filters, redirect_success, trans, + AppError, AppState, Context, CsbContext, CsbEvent, CsbStoreData, Form, HtmlTemplate, Locale, + PgStoreData, StreamId, csb::examination::CsbExaminationOverviewPath, filters, + persons::PersonId, redirect_success, store::Store, structs::common::BrpClient, trans, utils::parse_hash_prefix, }; @@ -119,9 +122,25 @@ async fn do_import( }) .await?; + do_brp_verification(&csb_store).await?; + Ok(redirect_success(CsbExaminationOverviewPath {})) } +pub async fn do_brp_verification( + store: &Store, +) -> Result, AppError> { + // TODO: get from env probably + let brp_client = BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + + let mut verified_persons = HashMap::new(); + for person in store.get_persons() { + verified_persons.insert(person.id, brp_client.verify(&person).await?); + } + + Ok(verified_persons) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/csb/store_csb/getters.rs b/src/csb/store_csb/getters.rs index 1295e35f4..6ad5e5234 100644 --- a/src/csb/store_csb/getters.rs +++ b/src/csb/store_csb/getters.rs @@ -227,6 +227,12 @@ impl CsbStore { .collect() } + pub fn get_persons(&self) -> Vec { + let data = self.data.read(); + + data.imported_data.persons.values().cloned().collect() + } + /// Return the single stored omission. Test-only helper for asserting on /// omissions whose category has no dedicated getter (e.g. candidate lists). #[cfg(test)] diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index d0fb04428..01729501e 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -41,7 +41,7 @@ impl BrpClient { } } - async fn verify(&self, person: &Person) -> Result { + pub async fn verify(&self, person: &Person) -> Result { let query = match person.personal_data.bsn { Some(BsnOrNoneConfirmed::Bsn(ref bsn)) => BrpQuery::ConsultWithBsn { bsn: vec![bsn.clone()], @@ -137,10 +137,6 @@ structstruck::strike! { bsn: Option, #[serde(rename = "geslacht")] gender: Option, diff --git a/src/structs/common/mod.rs b/src/structs/common/mod.rs index 19d879fd3..ce2f3b90d 100644 --- a/src/structs/common/mod.rs +++ b/src/structs/common/mod.rs @@ -23,6 +23,7 @@ mod severity; mod utc_date_time; pub use address::{Address, DutchAddress, InternationalAddress}; +pub use brp::BrpClient; pub use bsn::{BSN_NONE_CONFIRMATION, Bsn, BsnOrNoneConfirmed}; pub use constrained_string::{FirstName, LegalName, StateOrProvince, StreetName}; pub use countries::COUNTRY_CODES; From d24618c24e0965478580ce38fe4bb4eef56ed114 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Fri, 3 Jul 2026 12:19:00 +0200 Subject: [PATCH 11/32] Move BrpValidation event to CsbEvent enum --- locales/en/audit_log.yml | 1 + locales/nl/audit_log.yml | 1 + src/csb/import/pages/import.rs | 21 ++++++++++----------- src/csb/store_csb/event.rs | 9 +++++++++ src/csb/store_csb/mod.rs | 4 ++++ src/csb/store_main/mod.rs | 3 +++ 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/locales/en/audit_log.yml b/locales/en/audit_log.yml index 076c1dc16..a74a6ffb5 100644 --- a/locales/en/audit_log.yml +++ b/locales/en/audit_log.yml @@ -40,6 +40,7 @@ documents: empty: No events have been recorded yet. event: add_candidate_to_list: Added candidate to list + brp_validation: Validated candidate against the BRP create_candidate_list: Created list of candidates create_name_authorisation: Created statutory name and authorised agent create_omission: Created omission diff --git a/locales/nl/audit_log.yml b/locales/nl/audit_log.yml index f691cef02..3d3aff8eb 100644 --- a/locales/nl/audit_log.yml +++ b/locales/nl/audit_log.yml @@ -40,6 +40,7 @@ documents: empty: Er zijn nog geen gebeurtenissen vastgelegd. event: add_candidate_to_list: Kandidaat aan lijst toegevoegd + brp_validation: Kandidaat gevalideerd tegen de BRP create_candidate_list: Kandidatenlijst aangemaakt create_name_authorisation: Statutaire naam en gemachtigde aangemaakt create_omission: Verzuim aangemaakt diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index a813cdd34..78df1d378 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use askama::Template; use axum::{ extract::State, @@ -9,9 +7,8 @@ use serde::Deserialize; use crate::{ AppError, AppState, Context, CsbContext, CsbEvent, CsbStoreData, Form, HtmlTemplate, Locale, - PgStoreData, StreamId, csb::examination::CsbExaminationOverviewPath, filters, - persons::PersonId, redirect_success, store::Store, structs::common::BrpClient, trans, - utils::parse_hash_prefix, + PgStoreData, StreamId, csb::examination::CsbExaminationOverviewPath, filters, redirect_success, + store::Store, structs::common::BrpClient, trans, utils::parse_hash_prefix, }; use super::CsbImportPath; @@ -127,18 +124,20 @@ async fn do_import( Ok(redirect_success(CsbExaminationOverviewPath {})) } -pub async fn do_brp_verification( - store: &Store, -) -> Result, AppError> { +pub async fn do_brp_verification(store: &Store) -> Result<(), AppError> { // TODO: get from env probably let brp_client = BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); - let mut verified_persons = HashMap::new(); for person in store.get_persons() { - verified_persons.insert(person.id, brp_client.verify(&person).await?); + let event = CsbEvent::BrpValidation { + person: person.id, + valid: brp_client.verify(&person).await?, + }; + + store.update(event).await?; } - Ok(verified_persons) + Ok(()) } #[cfg(test)] diff --git a/src/csb/store_csb/event.rs b/src/csb/store_csb/event.rs index 35a6e6ed7..61f4a55ce 100644 --- a/src/csb/store_csb/event.rs +++ b/src/csb/store_csb/event.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::{ Event, PgEvent, PgStoreData, StreamId, + persons::PersonId, structs::csb::{Correction, Omission, OmissionId}, trans, utils::format_hash, @@ -38,6 +39,10 @@ pub enum CsbEvent { omission_id: OmissionId, }, UpdateCorrection(Correction), + BrpValidation { + person: PersonId, + valid: bool, + }, } impl Event for CsbEvent { @@ -50,6 +55,7 @@ impl Event for CsbEvent { | CsbEvent::UpdateOmission(_) | CsbEvent::DeleteOmission { .. } => "omission", CsbEvent::UpdateCorrection(_) => "correction", + CsbEvent::BrpValidation { .. } => "brp_validation", } } @@ -62,6 +68,7 @@ impl Event for CsbEvent { CsbEvent::UpdateOmission(_) => "update_omission", CsbEvent::DeleteOmission { .. } => "delete_omission", CsbEvent::UpdateCorrection(_) => "update_correction", + CsbEvent::BrpValidation { .. } => "brep_validation", } } @@ -76,6 +83,7 @@ impl Event for CsbEvent { CsbEvent::UpdateCorrection { .. } => { trans!("audit_log.event.update_correction", locale) } + CsbEvent::BrpValidation { .. } => trans!("audit_log.event.brp_validation", locale), } } @@ -96,6 +104,7 @@ impl Event for CsbEvent { CsbEvent::CreateOmission(o) | CsbEvent::UpdateOmission(o) => o.description.clone(), CsbEvent::DeleteOmission { omission_id } => omission_id.to_string(), CsbEvent::UpdateCorrection(_) => String::new(), + CsbEvent::BrpValidation { person, .. } => person.to_string(), } } diff --git a/src/csb/store_csb/mod.rs b/src/csb/store_csb/mod.rs index a4ea06301..2fa2fac33 100644 --- a/src/csb/store_csb/mod.rs +++ b/src/csb/store_csb/mod.rs @@ -30,6 +30,7 @@ pub struct CsbStoreData { pub(crate) omissions: HashMap, pub(crate) csb_corrected_persons: HashMap, pub(crate) csb_corrected_display_name: Option, + pub(crate) brp_validations: HashMap, } impl StoreData for CsbStoreData { @@ -107,6 +108,9 @@ impl StoreData for CsbStoreData { correction.apply(person); } }, + CsbEvent::BrpValidation { person, valid } => { + self.brp_validations.insert(person, valid); + } } } diff --git a/src/csb/store_main/mod.rs b/src/csb/store_main/mod.rs index a6459899d..59d5a1738 100644 --- a/src/csb/store_main/mod.rs +++ b/src/csb/store_main/mod.rs @@ -2,11 +2,13 @@ mod event; mod extractor; pub use event::CsbMainEvent; +use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::{ Scope, StreamId, + persons::PersonId, store::{StoreData, StoreEvent}, }; @@ -20,6 +22,7 @@ pub const CSB_MAIN_STREAM_ID: StreamId = StreamId(uuid::Uuid::from_u128( #[derive(Debug, Default, Serialize, Deserialize)] pub struct CsbMainStoreData { pub(crate) events: Vec>, + pub(crate) brp_verifications: HashMap, } impl StoreData for CsbMainStoreData { From 40edbe8b2066517bcbe6ef49161fec172a1940fd Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Fri, 3 Jul 2026 16:01:06 +0200 Subject: [PATCH 12/32] Create BrpField enum (WIP: address implementation broken) --- src/structs/common/brp.rs | 102 +++++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index 01729501e..6fc199d8d 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -46,12 +46,17 @@ impl BrpClient { Some(BsnOrNoneConfirmed::Bsn(ref bsn)) => BrpQuery::ConsultWithBsn { bsn: vec![bsn.clone()], fields: vec![ - // TODO: Create a Field type? - "burgerservicenummer".to_string(), - "geboorte".to_string(), - "geslacht".to_string(), - "naam".to_string(), - "verblijfplaats".to_string(), + BrpField::Bsn, + BrpField::DateOfBirth, + BrpField::Gender, + BrpField::Initials, + BrpField::LastNamePrefix, + BrpField::LastName, + BrpField::StreetName, + BrpField::HouseNumber, + BrpField::HouseNumberAddition, + BrpField::PostalCode, + BrpField::PlaceOfResidence, ], }, Some(BsnOrNoneConfirmed::NoneConfirmed) => { @@ -88,6 +93,9 @@ impl BrpClient { } }; + // dbg!(&person); + // dbg!(&brp_person); + Ok(address_is_valid && // Don't check First name (roepnaam) person.name.last_name == brp_person.name.last_name && @@ -101,6 +109,84 @@ impl BrpClient { } } +// ontbreekt: Aanduiding bijzonder Nederlanderschap +// ontbreekt: Ingangsdatum geldigheid met betrekking tot de elementen van de categorie Nationaliteit +#[derive(Debug, Serialize)] +pub enum BrpField { + // Personen + #[serde(rename = "burgerservicenummer")] + Bsn, + #[serde(rename = "naam.voornamen")] + FirstNames, + #[serde(rename = "naam.voorletters")] + Initials, + #[serde(rename = "naam.adellijkeTitelPredicaat")] + TitleOfNobility, + #[serde(rename = "naam.voorvoegsel")] + LastNamePrefix, + #[serde(rename = "naam.geslachtsnaam")] + LastName, + #[serde(rename = "geboorte.datum")] + DateOfBirth, + #[serde(rename = "geslacht")] + Gender, + #[serde(rename = "naam.aanduidingNaamgebruik")] + DesignatedNameUsage, + + // Nationaliteit + #[serde(rename = "nationaliteiten.nationaliteit")] + Nationality, + + // Partners + #[serde(rename = "partners.naam.voorvoegsel")] + PartnerLastNamePrefix, + #[serde(rename = "partners.naam.geslachtsnaam")] + PartnerLastName, + #[serde(rename = "partners.aangaanHuwelijkPartnerschap.datum")] + DateOfMarriage, + #[serde(rename = "partners.ontbindingHuwelijkPartnerschap")] + DateOfDissolutionMarriage, + + // Date of death + #[serde(rename = "overlijden.datum")] + DateOfDeath, + + // Place of residence + // TODO: What to do with Registratie Niet Ingezetenen? + #[serde(rename = "gemeenteVanInschrijving")] + RegisteredMunicipality, + #[serde(rename = "datumInschrijvingInGemeente")] + DateMunicipalRegistration, + #[serde(rename = "verblijfplaats.verblijfadres.korteStraatnaam")] + StreetName, + // I think this is 'Naam openbare ruimte' + #[serde(rename = "verblijfplaats.verblijfadres.officieleStraatnaam")] + OfficialStreetName, + #[serde(rename = "verblijfplaats.verblijfadres.huisnummer")] + HouseNumber, + #[serde(rename = "verblijfplaats.verblijfadres.huisletter")] + HouseLetter, + #[serde(rename = "verblijfplaats.verblijfadres.huisnummertoevoeging")] + HouseNumberAddition, + #[serde(rename = "verblijfplaats.verblijfadres.postcode")] + PostalCode, + #[serde(rename = "verblijfplaats.verblijfadres.woonplaats")] + PlaceOfResidence, + + // Not sure if these are correct. They should be specifically foreign, but they + // may also apply to interior addresses + #[serde(rename = "verblijfplaats.verblijfadres.land")] + CountryOfResidence, // Land adres buitenland + #[serde(rename = "verblijfplaats.datumVan")] + ResidenceDateFrom, // Datum aanvang adres buitenland + #[serde(rename = "verblijfplaats.verblijfadres.regel1")] + AddressLine1, // Regel 1 adres buitenland + #[serde(rename = "verblijfplaats.verblijfadres.regel2")] + AddressLine2, // Regel 2 adres buitenland + #[serde(rename = "verblijfplaats.verblijfadres.regel3")] + AddressLine3, // Regel 3 adres buitenland +} + #[derive(Debug, Serialize)] #[serde(tag = "type")] pub enum BrpQuery { @@ -108,7 +194,7 @@ pub enum BrpQuery { ConsultWithBsn { #[serde(rename = "burgerservicenummer")] bsn: Vec, - fields: Vec, + fields: Vec, }, } @@ -284,7 +370,7 @@ mod tests { BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); let query = BrpQuery::ConsultWithBsn { bsn: vec!["100600505".parse().unwrap()], - fields: vec!["naam".to_string()], + fields: vec![BrpField::LastName], }; let response = brp_client.get_persons(&query).await.unwrap(); From 0afddb0a02d23fd3bf7a36431b577f1756f0157e Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 6 Jul 2026 10:32:25 +0200 Subject: [PATCH 13/32] Fix address verification --- src/structs/common/brp.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index 6fc199d8d..607e2546a 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -52,7 +52,7 @@ impl BrpClient { BrpField::Initials, BrpField::LastNamePrefix, BrpField::LastName, - BrpField::StreetName, + BrpField::OfficialStreetName, BrpField::HouseNumber, BrpField::HouseNumberAddition, BrpField::PostalCode, @@ -93,9 +93,6 @@ impl BrpClient { } }; - // dbg!(&person); - // dbg!(&brp_person); - Ok(address_is_valid && // Don't check First name (roepnaam) person.name.last_name == brp_person.name.last_name && @@ -243,13 +240,13 @@ structstruck::strike! { date: Option }> }>, - #[serde(rename = "woonplaats")] + #[serde(rename = "verblijfplaats")] place_of_residence: Option< #[serde(tag = "type")] enum BrpPlaceOfResidence { #[serde(rename = "Adres")] Address { - #[serde(rename = "verblijfplaats")] + #[serde(rename = "verblijfadres")] residence_address: struct BrpAddress { // TODO: Confirm that this should be officieleStraatnaam // Or handle this by checking if either matches? If this is only used as a correspondence address, @@ -374,7 +371,8 @@ mod tests { }; let response = brp_client.get_persons(&query).await.unwrap(); - println!("{:?}", response); + let expected = "Digid 1 100600505 geslachtsnaam".parse().unwrap(); + assert!(response.first().unwrap().name.last_name == expected); } #[tokio::test] From f01b70beb346012e4a9c4f23359c8a9dc05eeb04 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 6 Jul 2026 11:54:07 +0200 Subject: [PATCH 14/32] Refactor brp module --- src/app/common/structs/brp/brp_person.rs | 157 +++++++++++++++++++++ src/app/common/structs/brp/client.rs | 168 +++++++++++++++++++++++ src/app/common/structs/brp/field.rs | 79 +++++++++++ src/app/common/structs/brp/mod.rs | 7 + 4 files changed, 411 insertions(+) create mode 100644 src/app/common/structs/brp/brp_person.rs create mode 100644 src/app/common/structs/brp/client.rs create mode 100644 src/app/common/structs/brp/field.rs create mode 100644 src/app/common/structs/brp/mod.rs diff --git a/src/app/common/structs/brp/brp_person.rs b/src/app/common/structs/brp/brp_person.rs new file mode 100644 index 000000000..f8f2c5aa5 --- /dev/null +++ b/src/app/common/structs/brp/brp_person.rs @@ -0,0 +1,157 @@ +use chrono::NaiveDate; +use serde::Deserialize; + +use crate::{ + common::{Bsn, BsnOrNoneConfirmed, DateOfBirth, DutchAddress, FullName}, + persons::PersonalData, +}; + +#[derive(Debug, Deserialize)] +#[serde(from = "BrpPersonRaw")] +pub struct BrpPerson { + pub name: FullName, + pub personal_data: PersonalData, + pub address: Option, +} + +structstruck::strike! { + #[structstruck::each[derive(Debug, Deserialize)]] + struct BrpPersonRaw { + #[serde(rename = "burgerservicenummer")] + bsn: Option, + #[serde(rename = "geslacht")] + gender: Option, + #[serde(rename = "naam")] + name: Option, + #[serde(rename = "voorvoegsel")] + last_name_prefix: Option, + #[serde(rename = "voorletters")] + initials: Option + }>, + #[serde(rename = "geboorte")] + birth: Option + }> + }>, + #[serde(rename = "verblijfplaats")] + place_of_residence: Option< + #[serde(tag = "type")] + enum BrpPlaceOfResidence { + #[serde(rename = "Adres")] + Address { + #[serde(rename = "verblijfadres")] + residence_address: struct BrpAddress { + // TODO: Confirm that this should be officieleStraatnaam + // Or handle this by checking if either matches? If this is only used as a correspondence address, + // then that should be sufficient + #[serde(rename = "officieleStraatnaam")] + street_name: Option, + #[serde(rename = "huisnummer")] + house_number: Option, + #[serde(rename = "huisnummertoevoeging")] + house_number_addition: Option, + #[serde(rename = "postcode")] + postal_code: Option, + #[serde(rename = "woonplaats")] + place_of_residence: Option, + } + }, + #[serde(other)] + NonDutchAddress + }> + } +} + +impl From for BrpPerson { + fn from(raw: BrpPersonRaw) -> Self { + let name = raw + .name + .map(|naam| FullName { + // First name isn't checked, because this does not necessarily need to be the same (roepnaam) + first_name: None, + last_name: naam + .last_name + .and_then(|s| s.parse().ok()) + .unwrap_or_default(), + last_name_prefix: naam.last_name_prefix.and_then(|s| s.parse().ok()), + initials: naam + .initials + .and_then(|s| s.parse().ok()) + .unwrap_or_default(), + }) + .unwrap_or_default(); + + let bsn = raw + .bsn + .and_then(|s| s.parse::().ok()) + .map(BsnOrNoneConfirmed::Bsn); + + let gender = raw.gender.and_then(|g| g.gender.parse().ok()); + + let date_of_birth = raw + .birth + .as_ref() + .and_then(|b| b.date.as_ref()) + .and_then(|d| d.date.as_ref()) + .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) + .map(DateOfBirth::from); + + let (address, place_of_residence) = match raw.place_of_residence { + Some(BrpPlaceOfResidence::Address { + residence_address: ra, + }) => { + let addr = Some(DutchAddress { + street_name: ra.street_name.and_then(|s| s.parse().ok()), + house_number: ra.house_number.and_then(|s| s.to_string().parse().ok()), + house_number_addition: ra.house_number_addition.and_then(|s| s.parse().ok()), + locality: ra + .place_of_residence + .as_deref() + .and_then(|s| s.parse().ok()), + postal_code: ra.postal_code.and_then(|s| s.parse().ok()), + // Known in BRP probably implies known in bag, I guess maybe this could be Some(true), but + // I don't think it matters + known_in_bag: None, + }); + + // TODO: Is place of residence really the same as locality (above). + // (though note that above is parsed as `Locality`, and below as `PlaceOfResidence`) + let por = ra.place_of_residence.and_then(|s| s.parse().ok()); + + (addr, por) + } + Some(BrpPlaceOfResidence::NonDutchAddress) => { + // TODO: How to handle this? Set the address to None and conduct an additional BRP check + // for the Authorised Person? + todo!("Not a Dutch Address") + } + None => { + eprintln!("Field 'verblijfplaats' not included"); + (None, None) + } + }; + + BrpPerson { + name, + personal_data: PersonalData { + gender, + bsn, + date_of_birth, + place_of_residence, + // TODO: Can country be None here? Because we check with the BRP whether the address is international. + // If it is, then `address` will be None (since we can't verify international addresses) and we know that + // instead, it is necesarry to verify the Authorised Person's address + country: None, + }, + address, + } + } +} diff --git a/src/app/common/structs/brp/client.rs b/src/app/common/structs/brp/client.rs new file mode 100644 index 000000000..6aa2bae05 --- /dev/null +++ b/src/app/common/structs/brp/client.rs @@ -0,0 +1,168 @@ +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +use crate::{ + AppError, + common::{ + Bsn, BsnOrNoneConfirmed, + structs::brp::{BrpField, BrpPerson}, + }, + persons::Person, +}; + +pub struct BrpClient { + http_client: Client, + base_url: String, + api_key: String, + persons_endpoint: String, +} + +impl BrpClient { + pub fn new(base_url: &str, api_key: &str, persons_endpoint: &str) -> Self { + Self { + http_client: Client::new(), + base_url: base_url.to_string(), + api_key: api_key.to_string(), + persons_endpoint: persons_endpoint.to_string(), + } + } + + pub async fn get_persons(&self, query: &BrpQuery) -> Result, AppError> { + let url = format!("{}/{}", self.base_url, self.persons_endpoint); + + let response = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(query) + .send() + .await?; + + match response.json::().await? { + BrpResponse::ConsultWithBsn { persons } => Ok(persons), + } + } + + pub async fn verify(&self, person: &Person) -> Result { + let query = match person.personal_data.bsn { + Some(BsnOrNoneConfirmed::Bsn(ref bsn)) => BrpQuery::ConsultWithBsn { + bsn: vec![bsn.clone()], + fields: vec![ + BrpField::Bsn, + BrpField::DateOfBirth, + BrpField::Gender, + BrpField::Initials, + BrpField::LastNamePrefix, + BrpField::LastName, + BrpField::OfficialStreetName, + BrpField::HouseNumber, + BrpField::HouseNumberAddition, + BrpField::PostalCode, + BrpField::PlaceOfResidence, + ], + }, + Some(BsnOrNoneConfirmed::NoneConfirmed) => { + unimplemented!("BRP search with address? Or manual verification") + } + None => { + unimplemented!( + "Return error, because this person should have a BSN (or none confirmed)?" + ) + } + }; + + let brp_persons = self.get_persons(&query).await?; + let brp_person = match brp_persons.as_slice() { + [] => todo!("Handle person not found"), + [brp_person] => brp_person, + [..] => todo!("Handle person not unique"), + }; + + let address_is_valid = match &brp_person.address { + Some(address) => { + // Check all, except `known_in_bag` + person.address.street_name == address.street_name + && person.address.house_number == address.house_number + && person.address.house_number_addition == address.house_number_addition + && person.address.locality == address.locality + && person.address.postal_code == address.postal_code + } + None => { + eprintln!( + "Not a Dutch Address or no address at all (because the field 'verblijfplaats' was not included)" + ); + true + } + }; + + Ok(address_is_valid && + // Don't check First name (roepnaam) + person.name.last_name == brp_person.name.last_name && + person.name.last_name_prefix == brp_person.name.last_name_prefix && + person.name.initials == brp_person.name.initials && + // Check all fields of personal_data except country, check gender only when filled in + brp_person.personal_data.bsn == person.personal_data.bsn && + brp_person.personal_data.date_of_birth == person.personal_data.date_of_birth && + // Gender field is optional, but if it is filled in, we check it + (brp_person.personal_data.gender == person.personal_data.gender || person.personal_data.gender.is_none())) + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +pub enum BrpQuery { + #[serde(rename = "RaadpleegMetBurgerservicenummer")] + ConsultWithBsn { + #[serde(rename = "burgerservicenummer")] + bsn: Vec, + fields: Vec, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum BrpResponse { + #[serde(rename = "RaadpleegMetBurgerservicenummer")] + ConsultWithBsn { + #[serde(rename = "personen")] + persons: Vec, + }, +} + +#[cfg(test)] +mod tests { + use crate::test_utils::sample_person_from_brp; + + use super::*; + + #[tokio::test] + async fn brp_request() { + let brp_client = + BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + let query = BrpQuery::ConsultWithBsn { + bsn: vec!["100600505".parse().unwrap()], + fields: vec![BrpField::LastName], + }; + + let response = brp_client.get_persons(&query).await.unwrap(); + let expected = "Digid 1 100600505 geslachtsnaam".parse().unwrap(); + assert!(response.first().unwrap().name.last_name == expected); + } + + #[tokio::test] + async fn brp_verify() { + let brp_client = + BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + + let person = sample_person_from_brp(); + + match brp_client.verify(&person).await { + Err(e) => panic!("brp verification error: {e}"), + Ok(false) => panic!( + "person could not be verified: {}", + serde_json::to_string_pretty(&person).unwrap() + ), + _ => {} + } + } +} diff --git a/src/app/common/structs/brp/field.rs b/src/app/common/structs/brp/field.rs new file mode 100644 index 000000000..e6da2d897 --- /dev/null +++ b/src/app/common/structs/brp/field.rs @@ -0,0 +1,79 @@ +use serde::Serialize; + +// ontbreekt: Aanduiding bijzonder Nederlanderschap +// ontbreekt: Ingangsdatum geldigheid met betrekking tot de elementen van de categorie Nationaliteit +#[derive(Debug, Serialize)] +pub enum BrpField { + // Personen + #[serde(rename = "burgerservicenummer")] + Bsn, + #[serde(rename = "naam.voornamen")] + FirstNames, + #[serde(rename = "naam.voorletters")] + Initials, + #[serde(rename = "naam.adellijkeTitelPredicaat")] + TitleOfNobility, + #[serde(rename = "naam.voorvoegsel")] + LastNamePrefix, + #[serde(rename = "naam.geslachtsnaam")] + LastName, + #[serde(rename = "geboorte.datum")] + DateOfBirth, + #[serde(rename = "geslacht")] + Gender, + #[serde(rename = "naam.aanduidingNaamgebruik")] + DesignatedNameUsage, + + // Nationaliteit + #[serde(rename = "nationaliteiten.nationaliteit")] + Nationality, + + // Partners + #[serde(rename = "partners.naam.voorvoegsel")] + PartnerLastNamePrefix, + #[serde(rename = "partners.naam.geslachtsnaam")] + PartnerLastName, + #[serde(rename = "partners.aangaanHuwelijkPartnerschap.datum")] + DateOfMarriage, + #[serde(rename = "partners.ontbindingHuwelijkPartnerschap")] + DateOfDissolutionMarriage, + + // Date of death + #[serde(rename = "overlijden.datum")] + DateOfDeath, + + // Place of residence + // TODO: What to do with Registratie Niet Ingezetenen? + #[serde(rename = "gemeenteVanInschrijving")] + RegisteredMunicipality, + #[serde(rename = "datumInschrijvingInGemeente")] + DateMunicipalRegistration, + #[serde(rename = "verblijfplaats.verblijfadres.korteStraatnaam")] + StreetName, + // I think this is 'Naam openbare ruimte' + #[serde(rename = "verblijfplaats.verblijfadres.officieleStraatnaam")] + OfficialStreetName, + #[serde(rename = "verblijfplaats.verblijfadres.huisnummer")] + HouseNumber, + #[serde(rename = "verblijfplaats.verblijfadres.huisletter")] + HouseLetter, + #[serde(rename = "verblijfplaats.verblijfadres.huisnummertoevoeging")] + HouseNumberAddition, + #[serde(rename = "verblijfplaats.verblijfadres.postcode")] + PostalCode, + #[serde(rename = "verblijfplaats.verblijfadres.woonplaats")] + PlaceOfResidence, + + // Not sure if these are correct. They should be specifically foreign, but they + // may also apply to interior addresses + #[serde(rename = "verblijfplaats.verblijfadres.land")] + CountryOfResidence, // Land adres buitenland + #[serde(rename = "verblijfplaats.datumVan")] + ResidenceDateFrom, // Datum aanvang adres buitenland + #[serde(rename = "verblijfplaats.verblijfadres.regel1")] + AddressLine1, // Regel 1 adres buitenland + #[serde(rename = "verblijfplaats.verblijfadres.regel2")] + AddressLine2, // Regel 2 adres buitenland + #[serde(rename = "verblijfplaats.verblijfadres.regel3")] + AddressLine3, // Regel 3 adres buitenland +} diff --git a/src/app/common/structs/brp/mod.rs b/src/app/common/structs/brp/mod.rs new file mode 100644 index 000000000..1e4395b43 --- /dev/null +++ b/src/app/common/structs/brp/mod.rs @@ -0,0 +1,7 @@ +mod brp_person; +mod client; +mod field; + +pub use brp_person::BrpPerson; +pub use client::BrpClient; +pub use field::BrpField; From 3e34520a013eec0cd647fbb93fc52bde5e157591 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 6 Jul 2026 14:09:36 +0200 Subject: [PATCH 15/32] Run BRP import on tokio task --- src/app/common/structs/brp/client.rs | 1 + src/csb/import/pages/import.rs | 57 ++++++++++++++++++++++------ src/csb/store_csb/mod.rs | 1 + src/structs/common/brp.rs | 1 + 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/app/common/structs/brp/client.rs b/src/app/common/structs/brp/client.rs index 6aa2bae05..9ecc333e7 100644 --- a/src/app/common/structs/brp/client.rs +++ b/src/app/common/structs/brp/client.rs @@ -10,6 +10,7 @@ use crate::{ persons::Person, }; +#[derive(Clone)] pub struct BrpClient { http_client: Client, base_url: String, diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index 78df1d378..13ddf8be6 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use askama::Template; use axum::{ extract::State, @@ -5,6 +7,7 @@ use axum::{ }; use serde::Deserialize; +use crate::structs::csb::{Omission, OmissionCategory}; use crate::{ AppError, AppState, Context, CsbContext, CsbEvent, CsbStoreData, Form, HtmlTemplate, Locale, PgStoreData, StreamId, csb::examination::CsbExaminationOverviewPath, filters, redirect_success, @@ -13,6 +16,8 @@ use crate::{ use super::CsbImportPath; +const BRP_COURTESY_TIMEOUT: Duration = Duration::from_secs(1); + #[derive(Template)] #[template(path = "csb/import/pages/import.html")] struct CsbImportTemplate { @@ -119,23 +124,51 @@ async fn do_import( }) .await?; - do_brp_verification(&csb_store).await?; + // TODO: get from env at higher level probably + let brp_client = BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + do_brp_verification(&csb_store, &brp_client).await?; Ok(redirect_success(CsbExaminationOverviewPath {})) } -pub async fn do_brp_verification(store: &Store) -> Result<(), AppError> { - // TODO: get from env probably - let brp_client = BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); - - for person in store.get_persons() { - let event = CsbEvent::BrpValidation { - person: person.id, - valid: brp_client.verify(&person).await?, - }; +pub async fn do_brp_verification( + store: &Store, + brp_client: &BrpClient, +) -> Result<(), AppError> { + let store = store.clone(); + let brp_client = brp_client.clone(); + + tokio::task::spawn(async move { + store.data.write().brp_verification_in_progress = true; + + let mut ticker = tokio::time::interval(BRP_COURTESY_TIMEOUT); + for person in store.get_persons() { + ticker.tick().await; + + match brp_client.verify(&person).await { + Ok(true) => {} + Ok(false) => { + let omission = Omission::new( + OmissionCategory::Candidate { + person: person.id, + lists: Vec::with_capacity(0), + }, + "dummy".to_string(), + "dummy".to_string(), + "dummy".to_string(), + ); + if let Err(err) = omission.create(&store).await { + tracing::error!("failed to record BRP omission for {}: {err}", person.id); + } + } + Err(err) => { + tracing::error!("BRP verification failed for {}: {err}", person.id); + } + } + } - store.update(event).await?; - } + store.data.write().brp_verification_in_progress = false; + }); Ok(()) } diff --git a/src/csb/store_csb/mod.rs b/src/csb/store_csb/mod.rs index 2fa2fac33..cca70cf9a 100644 --- a/src/csb/store_csb/mod.rs +++ b/src/csb/store_csb/mod.rs @@ -31,6 +31,7 @@ pub struct CsbStoreData { pub(crate) csb_corrected_persons: HashMap, pub(crate) csb_corrected_display_name: Option, pub(crate) brp_validations: HashMap, + pub(crate) brp_verification_in_progress: bool, } impl StoreData for CsbStoreData { diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index 607e2546a..f82d9276f 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -8,6 +8,7 @@ use crate::{ persons::{Person, PersonalData}, }; +#[derive(Clone)] pub struct BrpClient { http_client: Client, base_url: String, From fdf291702e3e54e92e06374701a3e411aa4ac6fd Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 6 Jul 2026 15:23:16 +0200 Subject: [PATCH 16/32] Make brp verify function return a vec of Omissions --- src/app/common/structs/brp/client.rs | 202 +++++++++++++++++++++++---- src/csb/import/pages/import.rs | 22 ++- 2 files changed, 183 insertions(+), 41 deletions(-) diff --git a/src/app/common/structs/brp/client.rs b/src/app/common/structs/brp/client.rs index 9ecc333e7..f4cb88e30 100644 --- a/src/app/common/structs/brp/client.rs +++ b/src/app/common/structs/brp/client.rs @@ -7,6 +7,7 @@ use crate::{ Bsn, BsnOrNoneConfirmed, structs::brp::{BrpField, BrpPerson}, }, + csb::{Omission, OmissionCategory}, persons::Person, }; @@ -44,7 +45,7 @@ impl BrpClient { } } - pub async fn verify(&self, person: &Person) -> Result { + pub async fn verify(&self, person: &Person) -> Result, AppError> { let query = match person.personal_data.bsn { Some(BsnOrNoneConfirmed::Bsn(ref bsn)) => BrpQuery::ConsultWithBsn { bsn: vec![bsn.clone()], @@ -66,46 +67,133 @@ impl BrpClient { unimplemented!("BRP search with address? Or manual verification") } None => { - unimplemented!( - "Return error, because this person should have a BSN (or none confirmed)?" - ) + // TODO: This needs to be implemented + tracing::warn!( + "Person {} currently does not have a BSN filled in (or none confirmed)\n{:?}", + person.id, + person + ); + return Err(AppError::GenericNotFound); } }; + let mut omissions = vec![]; + let mut add_omission = |description: &str, help_text: &str| { + omissions.push(Omission::new( + OmissionCategory::Candidate { + person: person.id, + list: None, + }, + // TODO: These should likely be user configurable and translatable + description.to_string(), + help_text.to_string(), + )); + }; + let brp_persons = self.get_persons(&query).await?; let brp_person = match brp_persons.as_slice() { - [] => todo!("Handle person not found"), + [] => { + add_omission( + "Er is geen persoon gevonden met dit burgerservicenummer", + "Controleer of er een fout is gemaakt bij het invoeren", + ); + return Ok(omissions); + } [brp_person] => brp_person, - [..] => todo!("Handle person not unique"), + [..] => { + add_omission( + "Er zijn meerder personen gevonden met dit burgerservicenummer", + "Controleer of er een fout is gemaakt bij het invoeren", + ); + return Ok(omissions); + } }; - let address_is_valid = match &brp_person.address { + match &brp_person.address { Some(address) => { // Check all, except `known_in_bag` - person.address.street_name == address.street_name - && person.address.house_number == address.house_number - && person.address.house_number_addition == address.house_number_addition - && person.address.locality == address.locality - && person.address.postal_code == address.postal_code + if person.address.street_name != address.street_name { + add_omission( + "De straatnaam komt niet overeen met de BRP", + "Controleer de straatnaam", + ); + } + if person.address.house_number != address.house_number { + add_omission( + "Het huisnummer komt niet overeen met de BRP", + "Controleer het huisnummer", + ); + } + if person.address.house_number_addition != address.house_number_addition { + add_omission( + "De huisnummertoevoeging komt niet overeen met de BRP", + "Controleer de huisnummertoevoeging", + ); + } + if person.address.locality != address.locality { + add_omission( + "De woonplaats komt niet overeen met de BRP", + "Controleer de woonplaats", + ); + } + if person.address.postal_code != address.postal_code { + add_omission( + "De postcode komt niet overeen met de BRP", + "Controleer de postcode", + ); + } } None => { - eprintln!( + tracing::warn!( "Not a Dutch Address or no address at all (because the field 'verblijfplaats' was not included)" ); - true } }; - Ok(address_is_valid && - // Don't check First name (roepnaam) - person.name.last_name == brp_person.name.last_name && - person.name.last_name_prefix == brp_person.name.last_name_prefix && - person.name.initials == brp_person.name.initials && - // Check all fields of personal_data except country, check gender only when filled in - brp_person.personal_data.bsn == person.personal_data.bsn && - brp_person.personal_data.date_of_birth == person.personal_data.date_of_birth && - // Gender field is optional, but if it is filled in, we check it - (brp_person.personal_data.gender == person.personal_data.gender || person.personal_data.gender.is_none())) + // Don't check first name (roepnaam) + if person.name.last_name != brp_person.name.last_name { + add_omission( + "De achternaam komt niet overeen met de BRP", + "Controleer de achternaam", + ); + } + if person.name.last_name_prefix != brp_person.name.last_name_prefix { + add_omission( + "Het voorvoegsel komt niet overeen met de BRP", + "Controleer het voorvoegsel", + ); + } + if person.name.initials != brp_person.name.initials { + add_omission( + "De voorletters komen niet overeen met de BRP", + "Controleer de voorletters", + ); + } + + // Check all fields of personal_data except country, check gender only when filled in + if brp_person.personal_data.bsn != person.personal_data.bsn { + add_omission( + "Het burgerservicenummer komt niet overeen met de BRP", + "Controleer het burgerservicenummer", + ); + } + if brp_person.personal_data.date_of_birth != person.personal_data.date_of_birth { + add_omission( + "De geboortedatum komt niet overeen met de BRP", + "Controleer de geboortedatum", + ); + } + // Gender field is optional, but if it is filled in, we check it + if person.personal_data.gender.is_some() + && brp_person.personal_data.gender != person.personal_data.gender + { + add_omission( + "Het geslacht komt niet overeen met de BRP", + "Controleer het geslacht", + ); + } + + Ok(omissions) } } @@ -132,7 +220,10 @@ pub enum BrpResponse { #[cfg(test)] mod tests { - use crate::test_utils::sample_person_from_brp; + use crate::{ + persons::PersonId, + test_utils::{sample_person, sample_person_from_brp}, + }; use super::*; @@ -159,11 +250,68 @@ mod tests { match brp_client.verify(&person).await { Err(e) => panic!("brp verification error: {e}"), - Ok(false) => panic!( - "person could not be verified: {}", + Ok(omissions) if !omissions.is_empty() => panic!( + "person could not be verified, omissions: {omissions:?}\nperson: {}", serde_json::to_string_pretty(&person).unwrap() ), _ => {} } } + + #[tokio::test] + async fn brp_verify_returns_omissions() { + let brp_client = + BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + + let mut person = sample_person(PersonId::new()); + // Dit bsn voldoet aan de 11-proef maar staat niet in de mock brp + person.personal_data.bsn = Some("123456782".parse().unwrap()); + match brp_client.verify(&person).await { + Ok(ommissions) => { + assert_eq!(ommissions.len(), 1); + let ommission = &ommissions[0]; + assert!(matches!( + ommission.category, + OmissionCategory::Candidate { .. } + )); + assert_eq!( + ommission.description, + "Er is geen persoon gevonden met dit burgerservicenummer", + ) + } + Err(e) => panic!("{e}"), + } + + let mut person = sample_person_from_brp(); + person.address.house_number_addition = Some("nope".parse().unwrap()); + match brp_client.verify(&person).await { + Ok(ommissions) => { + assert_eq!(ommissions.len(), 1); + let ommission = &ommissions[0]; + assert!(matches!( + ommission.category, + OmissionCategory::Candidate { .. } + )); + assert_eq!( + ommission.description, + "De huisnummertoevoeging komt niet overeen met de BRP", + ) + } + Err(e) => panic!("{e}"), + } + + let mut person = sample_person(PersonId::new()); + // De gegevens in de brp voor dit bsn komen in zijn geheel niet overeen. Dit zou kunnen voorkomen + // als het verkeerde bsn is ingevuld. + person.personal_data.bsn = Some("999992806".parse().unwrap()); + + dbg!(&person); + match brp_client.verify(&person).await { + Ok(ommissions) => { + dbg!(&ommissions); + assert_eq!(ommissions.len(), 8); + } + Err(e) => panic!("{e}"), + } + } } diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index 13ddf8be6..4efaf872f 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -7,7 +7,6 @@ use axum::{ }; use serde::Deserialize; -use crate::structs::csb::{Omission, OmissionCategory}; use crate::{ AppError, AppState, Context, CsbContext, CsbEvent, CsbStoreData, Form, HtmlTemplate, Locale, PgStoreData, StreamId, csb::examination::CsbExaminationOverviewPath, filters, redirect_success, @@ -146,19 +145,14 @@ pub async fn do_brp_verification( ticker.tick().await; match brp_client.verify(&person).await { - Ok(true) => {} - Ok(false) => { - let omission = Omission::new( - OmissionCategory::Candidate { - person: person.id, - lists: Vec::with_capacity(0), - }, - "dummy".to_string(), - "dummy".to_string(), - "dummy".to_string(), - ); - if let Err(err) = omission.create(&store).await { - tracing::error!("failed to record BRP omission for {}: {err}", person.id); + Ok(omissions) => { + for omission in omissions { + if let Err(err) = omission.create(&store).await { + tracing::error!( + "failed to record BRP omission for {}: {err}", + person.id + ); + } } } Err(err) => { From 3b9e896da13d3d38afd71c79246c17cc5baa1df6 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Thu, 9 Jul 2026 10:17:39 +0200 Subject: [PATCH 17/32] Add test to check if ommissions are added when a person is not successfully verified against the brp mock --- src/structs/common/brp.rs | 150 ++++++++++++++++++++++++++++++++------ 1 file changed, 126 insertions(+), 24 deletions(-) diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index f82d9276f..aa048207d 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -6,6 +6,7 @@ use crate::{ AppError, common::{Bsn, BsnOrNoneConfirmed, DateOfBirth, DutchAddress, FullName}, persons::{Person, PersonalData}, + structs::csb::{Omission, OmissionCategory}, }; #[derive(Clone)] @@ -42,7 +43,7 @@ impl BrpClient { } } - pub async fn verify(&self, person: &Person) -> Result { + pub async fn verify(&self, person: &Person) -> Result, AppError> { let query = match person.personal_data.bsn { Some(BsnOrNoneConfirmed::Bsn(ref bsn)) => BrpQuery::ConsultWithBsn { bsn: vec![bsn.clone()], @@ -64,46 +65,147 @@ impl BrpClient { unimplemented!("BRP search with address? Or manual verification") } None => { - unimplemented!( - "Return error, because this person should have a BSN (or none confirmed)?" - ) + // TODO: This needs to be implemented + tracing::warn!( + "Person {} currently does not have a BSN filled in (or none confirmed)\n{:?}", + person.id, + person + ); + return Err(AppError::GenericNotFound); } }; + let mut omissions = vec![]; + let mut add_omission = |title: &str, description: &str, help_text: &str| { + omissions.push(Omission::new( + OmissionCategory::Candidate { + person: person.id, + lists: Vec::new(), + }, + // TODO: These should likely be user configurable and translatable + title.to_string(), + description.to_string(), + help_text.to_string(), + )); + }; + let brp_persons = self.get_persons(&query).await?; let brp_person = match brp_persons.as_slice() { - [] => todo!("Handle person not found"), + [] => { + add_omission( + "Burgerservicenummer onbekend", + "Er is geen persoon gevonden met dit burgerservicenummer", + "Controleer of er een fout is gemaakt bij het invoeren", + ); + return Ok(omissions); + } [brp_person] => brp_person, - [..] => todo!("Handle person not unique"), + [..] => { + add_omission( + "Burgerservicenummer niet uniek", + "Er zijn meerder personen gevonden met dit burgerservicenummer", + "Controleer of er een fout is gemaakt bij het invoeren", + ); + return Ok(omissions); + } }; - let address_is_valid = match &brp_person.address { + match &brp_person.address { Some(address) => { // Check all, except `known_in_bag` - person.address.street_name == address.street_name - && person.address.house_number == address.house_number - && person.address.house_number_addition == address.house_number_addition - && person.address.locality == address.locality - && person.address.postal_code == address.postal_code + if person.address.street_name != address.street_name { + add_omission( + "Onjuiste straatnaam", + "De straatnaam komt niet overeen met de BRP", + "Controleer de straatnaam", + ); + } + if person.address.house_number != address.house_number { + add_omission( + "Onjuist huisnummer", + "Het huisnummer komt niet overeen met de BRP", + "Controleer het huisnummer", + ); + } + if person.address.house_number_addition != address.house_number_addition { + add_omission( + "Onjuiste huisnummertoevoeging", + "De huisnummertoevoeging komt niet overeen met de BRP", + "Controleer de huisnummertoevoeging", + ); + } + if person.address.locality != address.locality { + add_omission( + "Onjuiste woonplaats", + "De woonplaats komt niet overeen met de BRP", + "Controleer de woonplaats", + ); + } + if person.address.postal_code != address.postal_code { + add_omission( + "Onjuiste postcode", + "De postcode komt niet overeen met de BRP", + "Controleer de postcode", + ); + } } None => { - eprintln!( + tracing::warn!( "Not a Dutch Address or no address at all (because the field 'verblijfplaats' was not included)" ); - true } }; - Ok(address_is_valid && - // Don't check First name (roepnaam) - person.name.last_name == brp_person.name.last_name && - person.name.last_name_prefix == brp_person.name.last_name_prefix && - person.name.initials == brp_person.name.initials && - // Check all fields of personal_data except country, check gender only when filled in - brp_person.personal_data.bsn == person.personal_data.bsn && - brp_person.personal_data.date_of_birth == person.personal_data.date_of_birth && - // Gender field is optional, but if it is filled in, we check it - (brp_person.personal_data.gender == person.personal_data.gender || person.personal_data.gender.is_none())) + // Don't check first name (roepnaam) + if person.name.last_name != brp_person.name.last_name { + add_omission( + "Onjuiste achternaam", + "De achternaam komt niet overeen met de BRP", + "Controleer de achternaam", + ); + } + if person.name.last_name_prefix != brp_person.name.last_name_prefix { + add_omission( + "Onjuist voorvoegsel", + "Het voorvoegsel komt niet overeen met de BRP", + "Controleer het voorvoegsel", + ); + } + if person.name.initials != brp_person.name.initials { + add_omission( + "Onjuiste voorletters", + "De voorletters komen niet overeen met de BRP", + "Controleer de voorletters", + ); + } + + // Check all fields of personal_data except country, check gender only when filled in + if brp_person.personal_data.bsn != person.personal_data.bsn { + add_omission( + "Onjuist burgerservicenummer", + "Het burgerservicenummer komt niet overeen met de BRP", + "Controleer het burgerservicenummer", + ); + } + if brp_person.personal_data.date_of_birth != person.personal_data.date_of_birth { + add_omission( + "Onjuiste geboortedatum", + "De geboortedatum komt niet overeen met de BRP", + "Controleer de geboortedatum", + ); + } + // Gender field is optional, but if it is filled in, we check it + if person.personal_data.gender.is_some() + && brp_person.personal_data.gender != person.personal_data.gender + { + add_omission( + "Onjuist geslacht", + "Het geslacht komt niet overeen met de BRP", + "Controleer het geslacht", + ); + } + + Ok(omissions) } } From d9669f9e4957b556f7a6d40f41ff5fef86793774 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Thu, 9 Jul 2026 11:22:00 +0200 Subject: [PATCH 18/32] Add more tracing to do_brp_verification --- src/csb/import/pages/import.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index 4efaf872f..6a4d3e5de 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -142,6 +142,7 @@ pub async fn do_brp_verification( let mut ticker = tokio::time::interval(BRP_COURTESY_TIMEOUT); for person in store.get_persons() { + tracing::info!("Checking person {} against the brp", person.id); ticker.tick().await; match brp_client.verify(&person).await { @@ -161,6 +162,10 @@ pub async fn do_brp_verification( } } + tracing::info!( + "Finished checking candidates on list {:?}", + store.data.read().imported_data.political_group + ); store.data.write().brp_verification_in_progress = false; }); From 994fef2248447bd2bd269b0515b7b9162a6e45cb Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 27 Jul 2026 13:50:13 +0200 Subject: [PATCH 19/32] Fix clippy issues --- src/structs/common/brp.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs index aa048207d..92db35068 100644 --- a/src/structs/common/brp.rs +++ b/src/structs/common/brp.rs @@ -211,6 +211,10 @@ impl BrpClient { // ontbreekt: Aanduiding bijzonder Nederlanderschap // ontbreekt: Ingangsdatum geldigheid met betrekking tot de elementen van de categorie Nationaliteit +#[expect( + unused, + reason = "These are all the fields we can request from the BRP, not necessarily all the ones we need" +)] #[derive(Debug, Serialize)] pub enum BrpField { // Personen @@ -487,9 +491,10 @@ mod tests { match brp_client.verify(&person).await { Err(e) => panic!("brp verification error: {e}"), - Ok(false) => panic!( - "person could not be verified: {}", - serde_json::to_string_pretty(&person).unwrap() + Ok(omissions) if !omissions.is_empty() => panic!( + "person could not be verified: {}\nFollowing omissions were found: {:?}", + serde_json::to_string_pretty(&person).unwrap(), + omissions ), _ => {} } From 1be31e63407b5a4db1c472cac63e52adfd8631a7 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 27 Jul 2026 14:35:14 +0200 Subject: [PATCH 20/32] Refactor brp files into its own directory and delete orphaned files --- src/app/common/mod.rs | 27 - src/csb/import/pages/import.rs | 2 +- src/{app/common => }/structs/brp/client.rs | 31 +- src/{app/common => }/structs/brp/field.rs | 4 + src/{app/common => }/structs/brp/mod.rs | 4 +- .../brp_person.rs => structs/brp/person.rs} | 0 src/structs/common/brp.rs | 502 ------------------ src/structs/common/mod.rs | 2 - src/structs/mod.rs | 1 + 9 files changed, 30 insertions(+), 543 deletions(-) delete mode 100644 src/app/common/mod.rs rename src/{app/common => }/structs/brp/client.rs (91%) rename src/{app/common => }/structs/brp/field.rs (95%) rename src/{app/common => }/structs/brp/mod.rs (61%) rename src/{app/common/structs/brp/brp_person.rs => structs/brp/person.rs} (100%) delete mode 100644 src/structs/common/brp.rs diff --git a/src/app/common/mod.rs b/src/app/common/mod.rs deleted file mode 100644 index c5f856429..000000000 --- a/src/app/common/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Shared domain primitives and common pages. -//! -//! Scope: reusable value types (names, addresses, dates), shared extractors, -//! and common pages such as index and language switching. -mod extractors; -mod forms; -mod pages; -mod structs; - -pub use forms::{ - DutchAddressForm, FullNameForm, InternationalAddressForm, MinimalNameForm, SelectElectionForm, - SwitchElectionForm, -}; -pub use structs::{ - Address, BSN_NONE_CONFIRMATION, BrpClient, Bsn, BsnOrNoneConfirmed, COUNTRY_CODES, CountryCode, - DateOfBirth, DisplayName, DutchAddress, EmptyAddressProblems, FirstName, FormAction, FullName, - Gender, HasSeverity, HouseNumber, HouseNumberAddition, InfoProblems, Initials, - InternationalAddress, InternationalPostalCode, LastName, LastNamePrefix, LegalName, Locality, - PlaceOfResidence, PostalCode, PotentialProblems, PreviousElectionResults, Problematic, - Problems, Severity, StateOrProvince, StreetName, UtcDateTime, WithProblems, -}; - -pub use pages::{ - HideDownloadWarningPath, IndexPath, LoginStartPath, LogoutPath, SelectElectionPath, - SwitchElectionPath, SwitchLanguagePath, auth_failure_response, not_found, public_router, - router, session_only_router, wellknown_router, -}; diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index 34fae3da6..afda9ee7e 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -13,7 +13,7 @@ use crate::{ csb::examination::{CsbExaminationOverviewPath, CsbPoliticalGroupPath}, filters, redirect_success, store::Store, - structs::common::BrpClient, + structs::brp::BrpClient, trans, utils::parse_hash_prefix, }; diff --git a/src/app/common/structs/brp/client.rs b/src/structs/brp/client.rs similarity index 91% rename from src/app/common/structs/brp/client.rs rename to src/structs/brp/client.rs index f4cb88e30..84af9c01d 100644 --- a/src/app/common/structs/brp/client.rs +++ b/src/structs/brp/client.rs @@ -1,14 +1,12 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; +use super::{BrpField, BrpPerson}; use crate::{ AppError, - common::{ - Bsn, BsnOrNoneConfirmed, - structs::brp::{BrpField, BrpPerson}, - }, - csb::{Omission, OmissionCategory}, + common::{Bsn, BsnOrNoneConfirmed}, persons::Person, + structs::csb::{Omission, OmissionCategory}, }; #[derive(Clone)] @@ -78,13 +76,14 @@ impl BrpClient { }; let mut omissions = vec![]; - let mut add_omission = |description: &str, help_text: &str| { + let mut add_omission = |title: &str, description: &str, help_text: &str| { omissions.push(Omission::new( OmissionCategory::Candidate { person: person.id, - list: None, + lists: Vec::new(), }, // TODO: These should likely be user configurable and translatable + title.to_string(), description.to_string(), help_text.to_string(), )); @@ -94,6 +93,7 @@ impl BrpClient { let brp_person = match brp_persons.as_slice() { [] => { add_omission( + "Burgerservicenummer onbekend", "Er is geen persoon gevonden met dit burgerservicenummer", "Controleer of er een fout is gemaakt bij het invoeren", ); @@ -102,6 +102,7 @@ impl BrpClient { [brp_person] => brp_person, [..] => { add_omission( + "Burgerservicenummer niet uniek", "Er zijn meerder personen gevonden met dit burgerservicenummer", "Controleer of er een fout is gemaakt bij het invoeren", ); @@ -114,30 +115,35 @@ impl BrpClient { // Check all, except `known_in_bag` if person.address.street_name != address.street_name { add_omission( + "Onjuiste straatnaam", "De straatnaam komt niet overeen met de BRP", "Controleer de straatnaam", ); } if person.address.house_number != address.house_number { add_omission( + "Onjuist huisnummer", "Het huisnummer komt niet overeen met de BRP", "Controleer het huisnummer", ); } if person.address.house_number_addition != address.house_number_addition { add_omission( + "Onjuiste huisnummertoevoeging", "De huisnummertoevoeging komt niet overeen met de BRP", "Controleer de huisnummertoevoeging", ); } if person.address.locality != address.locality { add_omission( + "Onjuiste woonplaats", "De woonplaats komt niet overeen met de BRP", "Controleer de woonplaats", ); } if person.address.postal_code != address.postal_code { add_omission( + "Onjuiste postcode", "De postcode komt niet overeen met de BRP", "Controleer de postcode", ); @@ -153,18 +159,21 @@ impl BrpClient { // Don't check first name (roepnaam) if person.name.last_name != brp_person.name.last_name { add_omission( + "Onjuiste achternaam", "De achternaam komt niet overeen met de BRP", "Controleer de achternaam", ); } if person.name.last_name_prefix != brp_person.name.last_name_prefix { add_omission( + "Onjuist voorvoegsel", "Het voorvoegsel komt niet overeen met de BRP", "Controleer het voorvoegsel", ); } if person.name.initials != brp_person.name.initials { add_omission( + "Onjuiste voorletters", "De voorletters komen niet overeen met de BRP", "Controleer de voorletters", ); @@ -173,12 +182,14 @@ impl BrpClient { // Check all fields of personal_data except country, check gender only when filled in if brp_person.personal_data.bsn != person.personal_data.bsn { add_omission( + "Onjuist burgerservicenummer", "Het burgerservicenummer komt niet overeen met de BRP", "Controleer het burgerservicenummer", ); } if brp_person.personal_data.date_of_birth != person.personal_data.date_of_birth { add_omission( + "Onjuiste geboortedatum", "De geboortedatum komt niet overeen met de BRP", "Controleer de geboortedatum", ); @@ -188,6 +199,7 @@ impl BrpClient { && brp_person.personal_data.gender != person.personal_data.gender { add_omission( + "Onjuist geslacht", "Het geslacht komt niet overeen met de BRP", "Controleer het geslacht", ); @@ -251,8 +263,9 @@ mod tests { match brp_client.verify(&person).await { Err(e) => panic!("brp verification error: {e}"), Ok(omissions) if !omissions.is_empty() => panic!( - "person could not be verified, omissions: {omissions:?}\nperson: {}", - serde_json::to_string_pretty(&person).unwrap() + "person could not be verified: {}\nFollowing omissions were found: {:?}", + serde_json::to_string_pretty(&person).unwrap(), + omissions ), _ => {} } diff --git a/src/app/common/structs/brp/field.rs b/src/structs/brp/field.rs similarity index 95% rename from src/app/common/structs/brp/field.rs rename to src/structs/brp/field.rs index e6da2d897..16fc8c484 100644 --- a/src/app/common/structs/brp/field.rs +++ b/src/structs/brp/field.rs @@ -2,6 +2,10 @@ use serde::Serialize; // ontbreekt: Aanduiding bijzonder Nederlanderschap // ontbreekt: Ingangsdatum geldigheid met betrekking tot de elementen van de categorie Nationaliteit +#[expect( + unused, + reason = "These are all the fields we can request from the BRP, not necessarily all the ones we need" +)] #[derive(Debug, Serialize)] pub enum BrpField { // Personen diff --git a/src/app/common/structs/brp/mod.rs b/src/structs/brp/mod.rs similarity index 61% rename from src/app/common/structs/brp/mod.rs rename to src/structs/brp/mod.rs index 1e4395b43..960a706c4 100644 --- a/src/app/common/structs/brp/mod.rs +++ b/src/structs/brp/mod.rs @@ -1,7 +1,7 @@ -mod brp_person; mod client; mod field; +mod person; -pub use brp_person::BrpPerson; pub use client::BrpClient; pub use field::BrpField; +pub use person::BrpPerson; diff --git a/src/app/common/structs/brp/brp_person.rs b/src/structs/brp/person.rs similarity index 100% rename from src/app/common/structs/brp/brp_person.rs rename to src/structs/brp/person.rs diff --git a/src/structs/common/brp.rs b/src/structs/common/brp.rs deleted file mode 100644 index 92db35068..000000000 --- a/src/structs/common/brp.rs +++ /dev/null @@ -1,502 +0,0 @@ -use chrono::NaiveDate; -use reqwest::Client; -use serde::{Deserialize, Serialize}; - -use crate::{ - AppError, - common::{Bsn, BsnOrNoneConfirmed, DateOfBirth, DutchAddress, FullName}, - persons::{Person, PersonalData}, - structs::csb::{Omission, OmissionCategory}, -}; - -#[derive(Clone)] -pub struct BrpClient { - http_client: Client, - base_url: String, - api_key: String, - persons_endpoint: String, -} - -impl BrpClient { - pub fn new(base_url: &str, api_key: &str, persons_endpoint: &str) -> Self { - Self { - http_client: Client::new(), - base_url: base_url.to_string(), - api_key: api_key.to_string(), - persons_endpoint: persons_endpoint.to_string(), - } - } - - pub async fn get_persons(&self, query: &BrpQuery) -> Result, AppError> { - let url = format!("{}/{}", self.base_url, self.persons_endpoint); - - let response = self - .http_client - .post(&url) - .header("Authorization", format!("Bearer {}", self.api_key)) - .json(query) - .send() - .await?; - - match response.json::().await? { - BrpResponse::ConsultWithBsn { persons } => Ok(persons), - } - } - - pub async fn verify(&self, person: &Person) -> Result, AppError> { - let query = match person.personal_data.bsn { - Some(BsnOrNoneConfirmed::Bsn(ref bsn)) => BrpQuery::ConsultWithBsn { - bsn: vec![bsn.clone()], - fields: vec![ - BrpField::Bsn, - BrpField::DateOfBirth, - BrpField::Gender, - BrpField::Initials, - BrpField::LastNamePrefix, - BrpField::LastName, - BrpField::OfficialStreetName, - BrpField::HouseNumber, - BrpField::HouseNumberAddition, - BrpField::PostalCode, - BrpField::PlaceOfResidence, - ], - }, - Some(BsnOrNoneConfirmed::NoneConfirmed) => { - unimplemented!("BRP search with address? Or manual verification") - } - None => { - // TODO: This needs to be implemented - tracing::warn!( - "Person {} currently does not have a BSN filled in (or none confirmed)\n{:?}", - person.id, - person - ); - return Err(AppError::GenericNotFound); - } - }; - - let mut omissions = vec![]; - let mut add_omission = |title: &str, description: &str, help_text: &str| { - omissions.push(Omission::new( - OmissionCategory::Candidate { - person: person.id, - lists: Vec::new(), - }, - // TODO: These should likely be user configurable and translatable - title.to_string(), - description.to_string(), - help_text.to_string(), - )); - }; - - let brp_persons = self.get_persons(&query).await?; - let brp_person = match brp_persons.as_slice() { - [] => { - add_omission( - "Burgerservicenummer onbekend", - "Er is geen persoon gevonden met dit burgerservicenummer", - "Controleer of er een fout is gemaakt bij het invoeren", - ); - return Ok(omissions); - } - [brp_person] => brp_person, - [..] => { - add_omission( - "Burgerservicenummer niet uniek", - "Er zijn meerder personen gevonden met dit burgerservicenummer", - "Controleer of er een fout is gemaakt bij het invoeren", - ); - return Ok(omissions); - } - }; - - match &brp_person.address { - Some(address) => { - // Check all, except `known_in_bag` - if person.address.street_name != address.street_name { - add_omission( - "Onjuiste straatnaam", - "De straatnaam komt niet overeen met de BRP", - "Controleer de straatnaam", - ); - } - if person.address.house_number != address.house_number { - add_omission( - "Onjuist huisnummer", - "Het huisnummer komt niet overeen met de BRP", - "Controleer het huisnummer", - ); - } - if person.address.house_number_addition != address.house_number_addition { - add_omission( - "Onjuiste huisnummertoevoeging", - "De huisnummertoevoeging komt niet overeen met de BRP", - "Controleer de huisnummertoevoeging", - ); - } - if person.address.locality != address.locality { - add_omission( - "Onjuiste woonplaats", - "De woonplaats komt niet overeen met de BRP", - "Controleer de woonplaats", - ); - } - if person.address.postal_code != address.postal_code { - add_omission( - "Onjuiste postcode", - "De postcode komt niet overeen met de BRP", - "Controleer de postcode", - ); - } - } - None => { - tracing::warn!( - "Not a Dutch Address or no address at all (because the field 'verblijfplaats' was not included)" - ); - } - }; - - // Don't check first name (roepnaam) - if person.name.last_name != brp_person.name.last_name { - add_omission( - "Onjuiste achternaam", - "De achternaam komt niet overeen met de BRP", - "Controleer de achternaam", - ); - } - if person.name.last_name_prefix != brp_person.name.last_name_prefix { - add_omission( - "Onjuist voorvoegsel", - "Het voorvoegsel komt niet overeen met de BRP", - "Controleer het voorvoegsel", - ); - } - if person.name.initials != brp_person.name.initials { - add_omission( - "Onjuiste voorletters", - "De voorletters komen niet overeen met de BRP", - "Controleer de voorletters", - ); - } - - // Check all fields of personal_data except country, check gender only when filled in - if brp_person.personal_data.bsn != person.personal_data.bsn { - add_omission( - "Onjuist burgerservicenummer", - "Het burgerservicenummer komt niet overeen met de BRP", - "Controleer het burgerservicenummer", - ); - } - if brp_person.personal_data.date_of_birth != person.personal_data.date_of_birth { - add_omission( - "Onjuiste geboortedatum", - "De geboortedatum komt niet overeen met de BRP", - "Controleer de geboortedatum", - ); - } - // Gender field is optional, but if it is filled in, we check it - if person.personal_data.gender.is_some() - && brp_person.personal_data.gender != person.personal_data.gender - { - add_omission( - "Onjuist geslacht", - "Het geslacht komt niet overeen met de BRP", - "Controleer het geslacht", - ); - } - - Ok(omissions) - } -} - -// ontbreekt: Aanduiding bijzonder Nederlanderschap -// ontbreekt: Ingangsdatum geldigheid met betrekking tot de elementen van de categorie Nationaliteit -#[expect( - unused, - reason = "These are all the fields we can request from the BRP, not necessarily all the ones we need" -)] -#[derive(Debug, Serialize)] -pub enum BrpField { - // Personen - #[serde(rename = "burgerservicenummer")] - Bsn, - #[serde(rename = "naam.voornamen")] - FirstNames, - #[serde(rename = "naam.voorletters")] - Initials, - #[serde(rename = "naam.adellijkeTitelPredicaat")] - TitleOfNobility, - #[serde(rename = "naam.voorvoegsel")] - LastNamePrefix, - #[serde(rename = "naam.geslachtsnaam")] - LastName, - #[serde(rename = "geboorte.datum")] - DateOfBirth, - #[serde(rename = "geslacht")] - Gender, - #[serde(rename = "naam.aanduidingNaamgebruik")] - DesignatedNameUsage, - - // Nationaliteit - #[serde(rename = "nationaliteiten.nationaliteit")] - Nationality, - - // Partners - #[serde(rename = "partners.naam.voorvoegsel")] - PartnerLastNamePrefix, - #[serde(rename = "partners.naam.geslachtsnaam")] - PartnerLastName, - #[serde(rename = "partners.aangaanHuwelijkPartnerschap.datum")] - DateOfMarriage, - #[serde(rename = "partners.ontbindingHuwelijkPartnerschap")] - DateOfDissolutionMarriage, - - // Date of death - #[serde(rename = "overlijden.datum")] - DateOfDeath, - - // Place of residence - // TODO: What to do with Registratie Niet Ingezetenen? - #[serde(rename = "gemeenteVanInschrijving")] - RegisteredMunicipality, - #[serde(rename = "datumInschrijvingInGemeente")] - DateMunicipalRegistration, - #[serde(rename = "verblijfplaats.verblijfadres.korteStraatnaam")] - StreetName, - // I think this is 'Naam openbare ruimte' - #[serde(rename = "verblijfplaats.verblijfadres.officieleStraatnaam")] - OfficialStreetName, - #[serde(rename = "verblijfplaats.verblijfadres.huisnummer")] - HouseNumber, - #[serde(rename = "verblijfplaats.verblijfadres.huisletter")] - HouseLetter, - #[serde(rename = "verblijfplaats.verblijfadres.huisnummertoevoeging")] - HouseNumberAddition, - #[serde(rename = "verblijfplaats.verblijfadres.postcode")] - PostalCode, - #[serde(rename = "verblijfplaats.verblijfadres.woonplaats")] - PlaceOfResidence, - - // Not sure if these are correct. They should be specifically foreign, but they - // may also apply to interior addresses - #[serde(rename = "verblijfplaats.verblijfadres.land")] - CountryOfResidence, // Land adres buitenland - #[serde(rename = "verblijfplaats.datumVan")] - ResidenceDateFrom, // Datum aanvang adres buitenland - #[serde(rename = "verblijfplaats.verblijfadres.regel1")] - AddressLine1, // Regel 1 adres buitenland - #[serde(rename = "verblijfplaats.verblijfadres.regel2")] - AddressLine2, // Regel 2 adres buitenland - #[serde(rename = "verblijfplaats.verblijfadres.regel3")] - AddressLine3, // Regel 3 adres buitenland -} - -#[derive(Debug, Serialize)] -#[serde(tag = "type")] -pub enum BrpQuery { - #[serde(rename = "RaadpleegMetBurgerservicenummer")] - ConsultWithBsn { - #[serde(rename = "burgerservicenummer")] - bsn: Vec, - fields: Vec, - }, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "type")] -pub enum BrpResponse { - #[serde(rename = "RaadpleegMetBurgerservicenummer")] - ConsultWithBsn { - #[serde(rename = "personen")] - persons: Vec, - }, -} - -#[derive(Debug, Deserialize)] -#[serde(from = "BrpPersonRaw")] -pub struct BrpPerson { - name: FullName, - personal_data: PersonalData, - address: Option, -} - -structstruck::strike! { - #[structstruck::each[derive(Debug, Deserialize)]] - struct BrpPersonRaw { - #[serde(rename = "burgerservicenummer")] - bsn: Option, - #[serde(rename = "geslacht")] - gender: Option, - #[serde(rename = "naam")] - name: Option, - #[serde(rename = "voorvoegsel")] - last_name_prefix: Option, - #[serde(rename = "voorletters")] - initials: Option - }>, - #[serde(rename = "geboorte")] - birth: Option - }> - }>, - #[serde(rename = "verblijfplaats")] - place_of_residence: Option< - #[serde(tag = "type")] - enum BrpPlaceOfResidence { - #[serde(rename = "Adres")] - Address { - #[serde(rename = "verblijfadres")] - residence_address: struct BrpAddress { - // TODO: Confirm that this should be officieleStraatnaam - // Or handle this by checking if either matches? If this is only used as a correspondence address, - // then that should be sufficient - #[serde(rename = "officieleStraatnaam")] - street_name: Option, - #[serde(rename = "huisnummer")] - house_number: Option, - #[serde(rename = "huisnummertoevoeging")] - house_number_addition: Option, - #[serde(rename = "postcode")] - postal_code: Option, - #[serde(rename = "woonplaats")] - place_of_residence: Option, - } - }, - #[serde(other)] - NonDutchAddress - }> - } -} - -impl From for BrpPerson { - fn from(raw: BrpPersonRaw) -> Self { - let name = raw - .name - .map(|naam| FullName { - // First name isn't checked, because this does not necessarily need to be the same (roepnaam) - first_name: None, - last_name: naam - .last_name - .and_then(|s| s.parse().ok()) - .unwrap_or_default(), - last_name_prefix: naam.last_name_prefix.and_then(|s| s.parse().ok()), - initials: naam - .initials - .and_then(|s| s.parse().ok()) - .unwrap_or_default(), - }) - .unwrap_or_default(); - - let bsn = raw - .bsn - .and_then(|s| s.parse::().ok()) - .map(BsnOrNoneConfirmed::Bsn); - - let gender = raw.gender.and_then(|g| g.gender.parse().ok()); - - let date_of_birth = raw - .birth - .as_ref() - .and_then(|b| b.date.as_ref()) - .and_then(|d| d.date.as_ref()) - .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) - .map(DateOfBirth::from); - - let (address, place_of_residence) = match raw.place_of_residence { - Some(BrpPlaceOfResidence::Address { - residence_address: ra, - }) => { - let addr = Some(DutchAddress { - street_name: ra.street_name.and_then(|s| s.parse().ok()), - house_number: ra.house_number.and_then(|s| s.to_string().parse().ok()), - house_number_addition: ra.house_number_addition.and_then(|s| s.parse().ok()), - locality: ra - .place_of_residence - .as_deref() - .and_then(|s| s.parse().ok()), - postal_code: ra.postal_code.and_then(|s| s.parse().ok()), - // Known in BRP probably implies known in bag, I guess maybe this could be Some(true), but - // I don't think it matters - known_in_bag: None, - }); - - // TODO: Is place of residence really the same as locality (above). - // (though note that above is parsed as `Locality`, and below as `PlaceOfResidence`) - let por = ra.place_of_residence.and_then(|s| s.parse().ok()); - - (addr, por) - } - Some(BrpPlaceOfResidence::NonDutchAddress) => { - // TODO: How to handle this? Set the address to None and conduct an additional BRP check - // for the Authorised Person? - todo!("Not a Dutch Address") - } - None => { - eprintln!("Field 'verblijfplaats' not included"); - (None, None) - } - }; - - BrpPerson { - name, - personal_data: PersonalData { - gender, - bsn, - date_of_birth, - place_of_residence, - // TODO: Can country be None here? Because we check with the BRP whether the address is international. - // If it is, then `address` will be None (since we can't verify international addresses) and we know that - // instead, it is necesarry to verify the Authorised Person's address - country: None, - }, - address, - } - } -} - -#[cfg(test)] -mod tests { - use crate::test_utils::sample_person_from_brp; - - use super::*; - - #[tokio::test] - async fn brp_request() { - let brp_client = - BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); - let query = BrpQuery::ConsultWithBsn { - bsn: vec!["100600505".parse().unwrap()], - fields: vec![BrpField::LastName], - }; - - let response = brp_client.get_persons(&query).await.unwrap(); - let expected = "Digid 1 100600505 geslachtsnaam".parse().unwrap(); - assert!(response.first().unwrap().name.last_name == expected); - } - - #[tokio::test] - async fn brp_verify() { - let brp_client = - BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); - - let person = sample_person_from_brp(); - - match brp_client.verify(&person).await { - Err(e) => panic!("brp verification error: {e}"), - Ok(omissions) if !omissions.is_empty() => panic!( - "person could not be verified: {}\nFollowing omissions were found: {:?}", - serde_json::to_string_pretty(&person).unwrap(), - omissions - ), - _ => {} - } - } -} diff --git a/src/structs/common/mod.rs b/src/structs/common/mod.rs index ce2f3b90d..9647533af 100644 --- a/src/structs/common/mod.rs +++ b/src/structs/common/mod.rs @@ -1,5 +1,4 @@ mod address; -mod brp; mod bsn; mod constrained_string; mod countries; @@ -23,7 +22,6 @@ mod severity; mod utc_date_time; pub use address::{Address, DutchAddress, InternationalAddress}; -pub use brp::BrpClient; pub use bsn::{BSN_NONE_CONFIRMATION, Bsn, BsnOrNoneConfirmed}; pub use constrained_string::{FirstName, LegalName, StateOrProvince, StreetName}; pub use countries::COUNTRY_CODES; diff --git a/src/structs/mod.rs b/src/structs/mod.rs index a3370ad39..fcde16399 100644 --- a/src/structs/mod.rs +++ b/src/structs/mod.rs @@ -1,6 +1,7 @@ //! Shared domain model structs, used by both the political group section //! (`src/pg`) and the central voting bureau section (`src/csb`). pub mod audit_log; +pub mod brp; pub mod candidate_lists; pub mod candidates; pub mod common; From c332fc52bcf69b78324e73fb49e8cb0c22005b05 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 27 Jul 2026 15:01:18 +0200 Subject: [PATCH 21/32] Add test for brp verification upon csb import --- src/csb/import/pages/import.rs | 40 +++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index afda9ee7e..206137018 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -200,7 +200,8 @@ mod tests { use axum::http::StatusCode; use crate::{ - AppState, CsbContext, ElectionConfig, PgEvent, test_utils::response_body_string, + AppState, CsbContext, ElectionConfig, PgEvent, + test_utils::{response_body_string, sample_person_from_brp}, utils::format_hash, }; @@ -368,4 +369,41 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn brp_verification_records_an_omission_for_a_mismatched_person() -> Result<(), AppError> + { + let state = AppState::new_for_tests().await; + let csb_store = state + .csb_store_for_stream(StreamId::new(), ElectionConfig::EK27) + .await?; + + // This person matches a record in the local BRP mock exactly, except for + // the tampered field below, so the check should find one mismatch. + let mut person = sample_person_from_brp(); + person.address.house_number_addition = Some("nope".parse().unwrap()); + csb_store.add_person(person); + + let brp_client = + BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + do_brp_verification(&csb_store, &brp_client).await?; + + // The check runs in a spawned background task; poll briefly for its + // result instead of sleeping for the full courtesy timeout. + let mut omission = None; + for _ in 0..100 { + if let Some(o) = csb_store.data.read().omissions.values().next().cloned() { + omission = Some(o); + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let omission = omission.expect("BRP verification did not record an omission in time"); + assert_eq!( + omission.description, + "De huisnummertoevoeging komt niet overeen met de BRP" + ); + + Ok(()) + } } From 594d49c6cd27040a4a36441a7fdbc95bb0715bff Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 27 Jul 2026 16:47:17 +0200 Subject: [PATCH 22/32] Add timeout to brp_veriy and several other small improvements --- src/csb/import/pages/import.rs | 22 ++++++++++++++---- src/csb/store_csb/event.rs | 2 +- src/csb/store_csb/getters.rs | 6 ++--- src/structs/brp/client.rs | 42 ++++++++++++++++++++++++---------- src/structs/brp/person.rs | 5 ++-- 5 files changed, 53 insertions(+), 24 deletions(-) diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index 206137018..bb9234a8c 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -10,7 +10,10 @@ use serde::Deserialize; use crate::{ AppError, AppState, Context, CsbContext, CsbEvent, CsbStoreData, Form, HtmlTemplate, Locale, PgStoreData, StreamId, - csb::examination::{CsbExaminationOverviewPath, CsbPoliticalGroupPath}, + csb::{ + WithCorrections, + examination::{CsbExaminationOverviewPath, CsbPoliticalGroupPath}, + }, filters, redirect_success, store::Store, structs::brp::BrpClient, @@ -129,7 +132,12 @@ async fn do_import( .await?; // TODO: get from env at higher level probably - let brp_client = BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + let brp_client = BrpClient::new( + "http://localhost:5010", + "", + "haalcentraal/api/brp/personen", + Duration::from_secs(30), + ); do_brp_verification(&csb_store, &brp_client).await?; Ok(redirect_success(CsbPoliticalGroupPath { @@ -163,7 +171,7 @@ pub async fn do_brp_verification( store.data.write().brp_verification_in_progress = true; let mut ticker = tokio::time::interval(BRP_COURTESY_TIMEOUT); - for person in store.get_persons() { + for person in store.get_persons(WithCorrections::None) { tracing::info!("Checking person {} against the brp", person.id); ticker.tick().await; @@ -384,8 +392,12 @@ mod tests { person.address.house_number_addition = Some("nope".parse().unwrap()); csb_store.add_person(person); - let brp_client = - BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + let brp_client = BrpClient::new( + "http://localhost:5010", + "", + "haalcentraal/api/brp/personen", + Duration::from_secs(30), + ); do_brp_verification(&csb_store, &brp_client).await?; // The check runs in a spawned background task; poll briefly for its diff --git a/src/csb/store_csb/event.rs b/src/csb/store_csb/event.rs index 7b293530c..bcd7a3dac 100644 --- a/src/csb/store_csb/event.rs +++ b/src/csb/store_csb/event.rs @@ -72,7 +72,7 @@ impl Event for CsbEvent { CsbEvent::UpdateOmission(_) => "update_omission", CsbEvent::DeleteOmission { .. } => "delete_omission", CsbEvent::UpdateCorrection(_) => "update_correction", - CsbEvent::BrpValidation { .. } => "brep_validation", + CsbEvent::BrpValidation { .. } => "brp_validation", } } diff --git a/src/csb/store_csb/getters.rs b/src/csb/store_csb/getters.rs index 6ad5e5234..d9762aefc 100644 --- a/src/csb/store_csb/getters.rs +++ b/src/csb/store_csb/getters.rs @@ -227,10 +227,8 @@ impl CsbStore { .collect() } - pub fn get_persons(&self) -> Vec { - let data = self.data.read(); - - data.imported_data.persons.values().cloned().collect() + pub fn get_persons(&self, corrections: WithCorrections) -> Vec { + self.read(corrections).persons.values().cloned().collect() } /// Return the single stored omission. Test-only helper for asserting on diff --git a/src/structs/brp/client.rs b/src/structs/brp/client.rs index 84af9c01d..fee9cf37b 100644 --- a/src/structs/brp/client.rs +++ b/src/structs/brp/client.rs @@ -1,5 +1,6 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; +use std::time::Duration; use super::{BrpField, BrpPerson}; use crate::{ @@ -15,15 +16,17 @@ pub struct BrpClient { base_url: String, api_key: String, persons_endpoint: String, + timeout: Duration, } impl BrpClient { - pub fn new(base_url: &str, api_key: &str, persons_endpoint: &str) -> Self { + pub fn new(base_url: &str, api_key: &str, persons_endpoint: &str, timeout: Duration) -> Self { Self { http_client: Client::new(), base_url: base_url.to_string(), api_key: api_key.to_string(), persons_endpoint: persons_endpoint.to_string(), + timeout, } } @@ -35,6 +38,7 @@ impl BrpClient { .post(&url) .header("Authorization", format!("Bearer {}", self.api_key)) .json(query) + .timeout(self.timeout) .send() .await?; @@ -62,14 +66,18 @@ impl BrpClient { ], }, Some(BsnOrNoneConfirmed::NoneConfirmed) => { - unimplemented!("BRP search with address? Or manual verification") + // TODO: This needs to be implemented + tracing::error!( + "Person {} has BSN none confirmed. Use BRP search with address? Or manual verification", + person.id + ); + return Err(AppError::GenericNotFound); } None => { // TODO: This needs to be implemented tracing::warn!( - "Person {} currently does not have a BSN filled in (or none confirmed)\n{:?}", + "Person {} does not have a BSN filled in (or none confirmed)", person.id, - person ); return Err(AppError::GenericNotFound); } @@ -241,8 +249,12 @@ mod tests { #[tokio::test] async fn brp_request() { - let brp_client = - BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + let brp_client = BrpClient::new( + "http://localhost:5010", + "", + "haalcentraal/api/brp/personen", + Duration::from_secs(30), + ); let query = BrpQuery::ConsultWithBsn { bsn: vec!["100600505".parse().unwrap()], fields: vec![BrpField::LastName], @@ -255,8 +267,12 @@ mod tests { #[tokio::test] async fn brp_verify() { - let brp_client = - BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + let brp_client = BrpClient::new( + "http://localhost:5010", + "", + "haalcentraal/api/brp/personen", + Duration::from_secs(30), + ); let person = sample_person_from_brp(); @@ -273,8 +289,12 @@ mod tests { #[tokio::test] async fn brp_verify_returns_omissions() { - let brp_client = - BrpClient::new("http://localhost:5010", "", "haalcentraal/api/brp/personen"); + let brp_client = BrpClient::new( + "http://localhost:5010", + "", + "haalcentraal/api/brp/personen", + Duration::from_secs(30), + ); let mut person = sample_person(PersonId::new()); // Dit bsn voldoet aan de 11-proef maar staat niet in de mock brp @@ -318,10 +338,8 @@ mod tests { // als het verkeerde bsn is ingevuld. person.personal_data.bsn = Some("999992806".parse().unwrap()); - dbg!(&person); match brp_client.verify(&person).await { Ok(ommissions) => { - dbg!(&ommissions); assert_eq!(ommissions.len(), 8); } Err(e) => panic!("{e}"), diff --git a/src/structs/brp/person.rs b/src/structs/brp/person.rs index f8f2c5aa5..b03b1e133 100644 --- a/src/structs/brp/person.rs +++ b/src/structs/brp/person.rs @@ -131,10 +131,11 @@ impl From for BrpPerson { Some(BrpPlaceOfResidence::NonDutchAddress) => { // TODO: How to handle this? Set the address to None and conduct an additional BRP check // for the Authorised Person? - todo!("Not a Dutch Address") + tracing::error!("Person has an non-Dutch address"); + (None, None) } None => { - eprintln!("Field 'verblijfplaats' not included"); + tracing::error!("Field 'verblijfplaats' not included"); (None, None) } }; From 5f87f994cfb91f467bdc59b1b8f86856d8538353 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 27 Jul 2026 17:38:07 +0200 Subject: [PATCH 23/32] Add BrpValidationInProgress event to CsbStore --- locales/en/audit_log.yml | 1 + locales/nl/audit_log.yml | 1 + src/csb/import/pages/import.rs | 14 +++++++++++--- src/csb/store_csb/event.rs | 18 +++++++++++++----- src/csb/store_csb/mod.rs | 5 +++-- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/locales/en/audit_log.yml b/locales/en/audit_log.yml index f64fe4625..11b3969ba 100644 --- a/locales/en/audit_log.yml +++ b/locales/en/audit_log.yml @@ -61,6 +61,7 @@ event: import: Imported political group import_csv: Imported CSV remove_candidate_from_list: Removed candidate from list + set_brp_validation_state: Set BRP validation state set_finished: Set finished state update_candidate_list_districts: Updated electoral districts of list update_candidate_list_order: Updated order of list of candidates diff --git a/locales/nl/audit_log.yml b/locales/nl/audit_log.yml index 50a7c9681..6f73bf082 100644 --- a/locales/nl/audit_log.yml +++ b/locales/nl/audit_log.yml @@ -61,6 +61,7 @@ event: import: Politieke groepering geïmporteerd import_csv: CSV geïmporteerd remove_candidate_from_list: Kandidaat van lijst verwijderd + set_brp_validation_state: BRP validatie status gezet set_finished: Afgerond gezet update_candidate_list_districts: Kieskringen van lijst bijgewerkt update_candidate_list_order: Volgorde van kandidatenlijst bijgewerkt diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index bb9234a8c..b5a8d197f 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -167,9 +167,11 @@ pub async fn do_brp_verification( let store = store.clone(); let brp_client = brp_client.clone(); - tokio::task::spawn(async move { - store.data.write().brp_verification_in_progress = true; + store + .update(CsbEvent::SetBrpValidationInProgress(true)) + .await?; + tokio::task::spawn(async move { let mut ticker = tokio::time::interval(BRP_COURTESY_TIMEOUT); for person in store.get_persons(WithCorrections::None) { tracing::info!("Checking person {} against the brp", person.id); @@ -196,7 +198,13 @@ pub async fn do_brp_verification( "Finished checking candidates on list {:?}", store.data.read().imported_data.political_group ); - store.data.write().brp_verification_in_progress = false; + + if let Err(err) = store + .update(CsbEvent::SetBrpValidationInProgress(false)) + .await + { + tracing::error!("Failed to set BRP validation to false: {err}"); + } }); Ok(()) diff --git a/src/csb/store_csb/event.rs b/src/csb/store_csb/event.rs index bcd7a3dac..6047a6ba9 100644 --- a/src/csb/store_csb/event.rs +++ b/src/csb/store_csb/event.rs @@ -41,10 +41,11 @@ pub enum CsbEvent { omission_id: OmissionId, }, UpdateCorrection(Correction), - BrpValidation { + BrpPersonValidated { person: PersonId, valid: bool, }, + SetBrpValidationInProgress(bool), } impl Event for CsbEvent { @@ -58,7 +59,9 @@ impl Event for CsbEvent { | CsbEvent::UpdateOmission(_) | CsbEvent::DeleteOmission { .. } => "omission", CsbEvent::UpdateCorrection(_) => "correction", - CsbEvent::BrpValidation { .. } => "brp_validation", + CsbEvent::BrpPersonValidated { .. } | CsbEvent::SetBrpValidationInProgress(_) => { + "brp_validation" + } } } @@ -72,7 +75,8 @@ impl Event for CsbEvent { CsbEvent::UpdateOmission(_) => "update_omission", CsbEvent::DeleteOmission { .. } => "delete_omission", CsbEvent::UpdateCorrection(_) => "update_correction", - CsbEvent::BrpValidation { .. } => "brp_validation", + CsbEvent::BrpPersonValidated { .. } => "brp_person_validated", + Self::SetBrpValidationInProgress(_) => "brp_validation", } } @@ -88,7 +92,10 @@ impl Event for CsbEvent { CsbEvent::UpdateCorrection { .. } => { trans!("audit_log.event.update_correction", locale) } - CsbEvent::BrpValidation { .. } => trans!("audit_log.event.brp_validation", locale), + CsbEvent::BrpPersonValidated { .. } => trans!("audit_log.event.brp_validation", locale), + Self::SetBrpValidationInProgress(_) => { + trans!("audit_log.event.set_brp_validation_state", locale) + } } } @@ -110,7 +117,8 @@ impl Event for CsbEvent { CsbEvent::CreateOmission(o) | CsbEvent::UpdateOmission(o) => o.description.clone(), CsbEvent::DeleteOmission { omission_id } => omission_id.to_string(), CsbEvent::UpdateCorrection(_) => String::new(), - CsbEvent::BrpValidation { person, .. } => person.to_string(), + CsbEvent::BrpPersonValidated { person, .. } => person.to_string(), + CsbEvent::SetBrpValidationInProgress(value) => value.to_string(), } } diff --git a/src/csb/store_csb/mod.rs b/src/csb/store_csb/mod.rs index a435bdd4d..2852fd21b 100644 --- a/src/csb/store_csb/mod.rs +++ b/src/csb/store_csb/mod.rs @@ -31,7 +31,7 @@ pub struct CsbStoreData { pub(crate) csb_corrected_persons: HashMap, pub(crate) csb_corrected_display_name: Option, pub(crate) brp_validations: HashMap, - pub(crate) brp_verification_in_progress: bool, + pub(crate) brp_validation_in_progress: bool, } impl StoreData for CsbStoreData { @@ -110,9 +110,10 @@ impl StoreData for CsbStoreData { correction.apply(person); } }, - CsbEvent::BrpValidation { person, valid } => { + CsbEvent::BrpPersonValidated { person, valid } => { self.brp_validations.insert(person, valid); } + CsbEvent::SetBrpValidationInProgress(value) => self.brp_validation_in_progress = value, } } From b0e90ac722650187c5091db27b2f5adbb9f80695 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Wed, 29 Jul 2026 09:33:33 +0200 Subject: [PATCH 24/32] Make omission test more explicit and remove typo --- src/structs/brp/client.rs | 42 ++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/structs/brp/client.rs b/src/structs/brp/client.rs index fee9cf37b..e3a3e53b9 100644 --- a/src/structs/brp/client.rs +++ b/src/structs/brp/client.rs @@ -240,6 +240,8 @@ pub enum BrpResponse { #[cfg(test)] mod tests { + use std::collections::HashSet; + use crate::{ persons::PersonId, test_utils::{sample_person, sample_person_from_brp}, @@ -300,15 +302,15 @@ mod tests { // Dit bsn voldoet aan de 11-proef maar staat niet in de mock brp person.personal_data.bsn = Some("123456782".parse().unwrap()); match brp_client.verify(&person).await { - Ok(ommissions) => { - assert_eq!(ommissions.len(), 1); - let ommission = &ommissions[0]; + Ok(omissions) => { + assert_eq!(omissions.len(), 1); + let omission = &omissions[0]; assert!(matches!( - ommission.category, + omission.category, OmissionCategory::Candidate { .. } )); assert_eq!( - ommission.description, + omission.description, "Er is geen persoon gevonden met dit burgerservicenummer", ) } @@ -318,15 +320,15 @@ mod tests { let mut person = sample_person_from_brp(); person.address.house_number_addition = Some("nope".parse().unwrap()); match brp_client.verify(&person).await { - Ok(ommissions) => { - assert_eq!(ommissions.len(), 1); - let ommission = &ommissions[0]; + Ok(omissions) => { + assert_eq!(omissions.len(), 1); + let omission = &omissions[0]; assert!(matches!( - ommission.category, + omission.category, OmissionCategory::Candidate { .. } )); assert_eq!( - ommission.description, + omission.description, "De huisnummertoevoeging komt niet overeen met de BRP", ) } @@ -338,9 +340,25 @@ mod tests { // als het verkeerde bsn is ingevuld. person.personal_data.bsn = Some("999992806".parse().unwrap()); + let expected_titles: HashSet = [ + "Onjuist huisnummer".to_string(), + "Onjuiste achternaam".to_string(), + "Onjuiste geboortedatum".to_string(), + "Onjuiste huisnummertoevoeging".to_string(), + "Onjuiste postcode".to_string(), + "Onjuiste straatnaam".to_string(), + "Onjuiste voorletters".to_string(), + "Onjuiste woonplaats".to_string(), + ] + .into(); + match brp_client.verify(&person).await { - Ok(ommissions) => { - assert_eq!(ommissions.len(), 8); + Ok(omissions) => { + let actual_titles = HashSet::from_iter(omissions.into_iter().map(|o| o.title)); + assert_eq!( + expected_titles.symmetric_difference(&actual_titles).count(), + 0 + ) } Err(e) => panic!("{e}"), } From 06f11b34363ca0c9bbc08d61ad3f144fc36aa215 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Wed, 29 Jul 2026 14:51:45 +0200 Subject: [PATCH 25/32] Add BrpClient to the AppState --- src/core/config.rs | 47 ++++++++++++++++++++++++++++++++-- src/csb/import/pages/import.rs | 13 +++------- src/state.rs | 18 +++++++++++++ src/structs/brp/client.rs | 3 +++ src/structs/brp/field.rs | 4 --- src/structs/brp/mod.rs | 2 +- 6 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index 90a2bec1c..33c6cf8bc 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1,11 +1,14 @@ //! Loads runtime configuration from environment variables for AppState. //! Used by AppState::new to construct service URLs and storage settings. -use std::{env, path::PathBuf}; +use std::{env, path::PathBuf, time::Duration}; use secrecy::SecretString; -use crate::AppError; +use crate::{ + AppError, + structs::brp::{BRP_PERSONS_ENDPOINT, BRP_TIMEOUT}, +}; #[cfg(feature = "dev-features")] mod dev_defaults { @@ -20,11 +23,16 @@ mod dev_defaults { pub(super) const DEFAULT_MASTER_ENCRYPTION_KEY: &str = "eks-dev-master-encryption-key-not-for-production"; + pub(super) const BRP_API_KEY: &str = ""; + pub(super) const BRP_BASE_URL: &str = "http://localhost:5010"; + pub(super) fn lookup(name: &'static str) -> Result { std::collections::HashMap::from([ ("STORAGE_URL", STORAGE_URL), ("ID_DERIVATION_KEY", ID_DERIVATION_KEY), ("MASTER_ENCRYPTION_KEY", DEFAULT_MASTER_ENCRYPTION_KEY), + ("BRP_BASE_URL", BRP_BASE_URL), + ("BRP_API_KEY", BRP_API_KEY), ]) .get(name) .map(|value| (*value).to_string()) @@ -39,6 +47,15 @@ pub struct TlsConfig { pub key_path: PathBuf, } +/// TLS configuration for serving HTTPS via rustls. +#[derive(Debug, Clone)] +pub struct BrpConfig { + pub base_url: String, + pub api_key: String, + pub persons_endpoint: String, + pub timeout: Duration, +} + /// Runtime configuration loaded from environment variables. #[derive(Debug, Clone)] pub struct Config { @@ -61,6 +78,7 @@ pub struct Config { /// container. Set via `DISABLE_AUTH_SERVICE` (`1`, `true`, or `yes`, /// case-insensitive); anything else leaves the auth-service enabled. pub disable_auth_service: bool, + pub brp_client: BrpConfig, } fn get_env_with(name: &'static str, lookup: &mut F) -> Result @@ -117,6 +135,24 @@ impl Config { ) }); + let base_url = get_env_with("BRP_BASE_URL", &mut lookup)?; + let api_key = get_env_with("BRP_API_KEY", &mut lookup)?; + + let timeout: u64 = lookup("BRP_TIMEOUT") + .unwrap_or(BRP_TIMEOUT.to_string()) + .parse() + .map_err(|_| { + AppError::ConfigLoadError("Invalid BRP_TIMEOUT; please enter a number".to_string()) + })?; + + let brp_client = BrpConfig { + base_url, + api_key, + persons_endpoint: lookup("BRP_PERSONS_ENDPOINT") + .unwrap_or(BRP_PERSONS_ENDPOINT.to_string()), + timeout: Duration::from_secs(timeout), + }; + Ok(Self { storage_url: SecretString::from(storage_url), id_derivation_key: SecretString::from(id_derivation_key), @@ -125,6 +161,7 @@ impl Config { server_name, eks_key, disable_auth_service, + brp_client, }) } @@ -138,6 +175,12 @@ impl Config { server_name: None, eks_key: None, disable_auth_service: false, + brp_client: BrpConfig { + base_url: "http://localhost:5010".to_string(), + api_key: "".to_string(), + persons_endpoint: BRP_PERSONS_ENDPOINT.to_string(), + timeout: Duration::from_secs(BRP_TIMEOUT), + }, } } } diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index b5a8d197f..aaf7e214a 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -16,7 +16,7 @@ use crate::{ }, filters, redirect_success, store::Store, - structs::brp::BrpClient, + structs::brp::{BRP_PERSONS_ENDPOINT, BrpClient}, trans, utils::parse_hash_prefix, }; @@ -131,14 +131,7 @@ async fn do_import( }) .await?; - // TODO: get from env at higher level probably - let brp_client = BrpClient::new( - "http://localhost:5010", - "", - "haalcentraal/api/brp/personen", - Duration::from_secs(30), - ); - do_brp_verification(&csb_store, &brp_client).await?; + do_brp_verification(&csb_store, &state.brp_client).await?; Ok(redirect_success(CsbPoliticalGroupPath { stream_id: csb_store.stream_id, @@ -403,7 +396,7 @@ mod tests { let brp_client = BrpClient::new( "http://localhost:5010", "", - "haalcentraal/api/brp/personen", + BRP_PERSONS_ENDPOINT, Duration::from_secs(30), ); do_brp_verification(&csb_store, &brp_client).await?; diff --git a/src/state.rs b/src/state.rs index 0c3588e1b..5a200a280 100644 --- a/src/state.rs +++ b/src/state.rs @@ -11,6 +11,7 @@ use crate::{ crypto::MasterKey, csb::CSB_MAIN_STREAM_ID, store::{Store, StoreRegistry}, + structs::brp::BrpClient, }; #[cfg(feature = "fixtures")] @@ -34,6 +35,7 @@ pub struct AppState { pub id_deriver: IdDeriver, pub auth_service_state: AuthServiceState, pub db_health: DbHealth, + pub brp_client: BrpClient, } /// Contract the application's request extractors expect from the router @@ -80,6 +82,13 @@ impl AppState { AuthServiceState::new_from_env().await? }; + let brp_client = BrpClient::new( + &config.brp_client.base_url, + &config.brp_client.api_key, + &config.brp_client.persons_endpoint, + config.brp_client.timeout, + ); + Ok(Self { config: Box::leak(Box::new(config)), store_registry, @@ -90,6 +99,7 @@ impl AppState { id_deriver, auth_service_state, db_health: DbHealth::default(), + brp_client, }) } @@ -178,6 +188,13 @@ impl AppState { let csb_main_store_registry = StoreRegistry::with_persistence(store_registry.persistence().clone(), master); + let brp_client = BrpClient::new( + &config.brp_client.base_url, + &config.brp_client.api_key, + &config.brp_client.persons_endpoint, + config.brp_client.timeout, + ); + Self { store_registry, csb_store_registry, @@ -188,6 +205,7 @@ impl AppState { id_deriver, auth_service_state, db_health: DbHealth::default(), + brp_client, } } } diff --git a/src/structs/brp/client.rs b/src/structs/brp/client.rs index e3a3e53b9..a6cf8d640 100644 --- a/src/structs/brp/client.rs +++ b/src/structs/brp/client.rs @@ -10,6 +10,9 @@ use crate::{ structs::csb::{Omission, OmissionCategory}, }; +pub const BRP_PERSONS_ENDPOINT: &str = "haalcentraal/api/brp/personen"; +pub const BRP_TIMEOUT: u64 = 30; + #[derive(Clone)] pub struct BrpClient { http_client: Client, diff --git a/src/structs/brp/field.rs b/src/structs/brp/field.rs index 16fc8c484..e6da2d897 100644 --- a/src/structs/brp/field.rs +++ b/src/structs/brp/field.rs @@ -2,10 +2,6 @@ use serde::Serialize; // ontbreekt: Aanduiding bijzonder Nederlanderschap // ontbreekt: Ingangsdatum geldigheid met betrekking tot de elementen van de categorie Nationaliteit -#[expect( - unused, - reason = "These are all the fields we can request from the BRP, not necessarily all the ones we need" -)] #[derive(Debug, Serialize)] pub enum BrpField { // Personen diff --git a/src/structs/brp/mod.rs b/src/structs/brp/mod.rs index 960a706c4..1df24c25f 100644 --- a/src/structs/brp/mod.rs +++ b/src/structs/brp/mod.rs @@ -2,6 +2,6 @@ mod client; mod field; mod person; -pub use client::BrpClient; +pub use client::{BRP_PERSONS_ENDPOINT, BRP_TIMEOUT, BrpClient}; pub use field::BrpField; pub use person::BrpPerson; From 2edd27bc7d948623a4f2c80b08bf842b1ec043b1 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Wed, 29 Jul 2026 16:44:59 +0200 Subject: [PATCH 26/32] Skip persons that have already been validated against the brp --- src/csb/import/pages/import.rs | 10 +++++++++- src/csb/store_csb/getters.rs | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index aaf7e214a..b2658e1ea 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -16,7 +16,7 @@ use crate::{ }, filters, redirect_success, store::Store, - structs::brp::{BRP_PERSONS_ENDPOINT, BrpClient}, + structs::brp::BrpClient, trans, utils::parse_hash_prefix, }; @@ -164,9 +164,16 @@ pub async fn do_brp_verification( .update(CsbEvent::SetBrpValidationInProgress(true)) .await?; + let already_validated = store.get_brp_validations(); + tokio::task::spawn(async move { let mut ticker = tokio::time::interval(BRP_COURTESY_TIMEOUT); for person in store.get_persons(WithCorrections::None) { + if already_validated.contains_key(&person.id) { + tracing::info!("Person {} has already been validated", person.id); + continue; + } + tracing::info!("Checking person {} against the brp", person.id); ticker.tick().await; @@ -210,6 +217,7 @@ mod tests { use crate::{ AppState, CsbContext, ElectionConfig, PgEvent, + structs::brp::BRP_PERSONS_ENDPOINT, test_utils::{response_body_string, sample_person_from_brp}, utils::format_hash, }; diff --git a/src/csb/store_csb/getters.rs b/src/csb/store_csb/getters.rs index d9762aefc..39a8e44b9 100644 --- a/src/csb/store_csb/getters.rs +++ b/src/csb/store_csb/getters.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use parking_lot::{ RawRwLock, lock_api::{MappedRwLockReadGuard, RwLockReadGuard}, @@ -231,6 +233,10 @@ impl CsbStore { self.read(corrections).persons.values().cloned().collect() } + pub fn get_brp_validations(&self) -> HashMap { + self.data.read().brp_validations.clone() + } + /// Return the single stored omission. Test-only helper for asserting on /// omissions whose category has no dedicated getter (e.g. candidate lists). #[cfg(test)] From 3387452958d7667ed602641a00df52dc4476ea23 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Thu, 30 Jul 2026 10:12:36 +0200 Subject: [PATCH 27/32] Add candidate lists to omission category for brp validation --- src/csb/import/pages/import.rs | 9 ++++++- src/structs/brp/client.rs | 49 +++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index b2658e1ea..1eafbef1f 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -174,10 +174,17 @@ pub async fn do_brp_verification( continue; } + let candidate_lists = store + .get_candidate_lists(WithCorrections::None) + .iter() + .filter(|cl| cl.candidates.contains(&person.id)) + .map(|cl| cl.id) + .collect(); + tracing::info!("Checking person {} against the brp", person.id); ticker.tick().await; - match brp_client.verify(&person).await { + match brp_client.verify(&person, candidate_lists).await { Ok(omissions) => { for omission in omissions { if let Err(err) = omission.create(&store).await { diff --git a/src/structs/brp/client.rs b/src/structs/brp/client.rs index a6cf8d640..2d4cf034d 100644 --- a/src/structs/brp/client.rs +++ b/src/structs/brp/client.rs @@ -5,6 +5,7 @@ use std::time::Duration; use super::{BrpField, BrpPerson}; use crate::{ AppError, + candidate_lists::CandidateListId, common::{Bsn, BsnOrNoneConfirmed}, persons::Person, structs::csb::{Omission, OmissionCategory}, @@ -50,7 +51,11 @@ impl BrpClient { } } - pub async fn verify(&self, person: &Person) -> Result, AppError> { + pub async fn verify( + &self, + person: &Person, + candidate_lists: Vec, + ) -> Result, AppError> { let query = match person.personal_data.bsn { Some(BsnOrNoneConfirmed::Bsn(ref bsn)) => BrpQuery::ConsultWithBsn { bsn: vec![bsn.clone()], @@ -91,7 +96,7 @@ impl BrpClient { omissions.push(Omission::new( OmissionCategory::Candidate { person: person.id, - lists: Vec::new(), + lists: candidate_lists.clone(), }, // TODO: These should likely be user configurable and translatable title.to_string(), @@ -281,7 +286,7 @@ mod tests { let person = sample_person_from_brp(); - match brp_client.verify(&person).await { + match brp_client.verify(&person, Vec::new()).await { Err(e) => panic!("brp verification error: {e}"), Ok(omissions) if !omissions.is_empty() => panic!( "person could not be verified: {}\nFollowing omissions were found: {:?}", @@ -301,17 +306,18 @@ mod tests { Duration::from_secs(30), ); + let list_id = CandidateListId::new(); let mut person = sample_person(PersonId::new()); // Dit bsn voldoet aan de 11-proef maar staat niet in de mock brp person.personal_data.bsn = Some("123456782".parse().unwrap()); - match brp_client.verify(&person).await { + match brp_client.verify(&person, vec![list_id]).await { Ok(omissions) => { assert_eq!(omissions.len(), 1); let omission = &omissions[0]; - assert!(matches!( - omission.category, - OmissionCategory::Candidate { .. } - )); + let OmissionCategory::Candidate { lists, .. } = &omission.category else { + panic!("Unexpected omission category") + }; + assert_eq!(lists, &[list_id]); assert_eq!( omission.description, "Er is geen persoon gevonden met dit burgerservicenummer", @@ -322,7 +328,7 @@ mod tests { let mut person = sample_person_from_brp(); person.address.house_number_addition = Some("nope".parse().unwrap()); - match brp_client.verify(&person).await { + match brp_client.verify(&person, Vec::new()).await { Ok(omissions) => { assert_eq!(omissions.len(), 1); let omission = &omissions[0]; @@ -355,7 +361,7 @@ mod tests { ] .into(); - match brp_client.verify(&person).await { + match brp_client.verify(&person, Vec::new()).await { Ok(omissions) => { let actual_titles = HashSet::from_iter(omissions.into_iter().map(|o| o.title)); assert_eq!( @@ -366,4 +372,27 @@ mod tests { Err(e) => panic!("{e}"), } } + + #[tokio::test] + async fn omission_includes_candidate_lists() { + let brp_client = BrpClient::new( + "http://localhost:5010", + "", + "haalcentraal/api/brp/personen", + Duration::from_secs(30), + ); + + let person = sample_person_from_brp(); + let list_id = CandidateListId::new(); + + match brp_client.verify(&person, vec![list_id]).await { + Err(e) => panic!("brp verification error: {e}"), + Ok(omissions) if !omissions.is_empty() => panic!( + "person could not be verified: {}\nFollowing omissions were found: {:?}", + serde_json::to_string_pretty(&person).unwrap(), + omissions + ), + _ => {} + } + } } From e791d4e2c3d80a2bbff28d325134125225c6b45d Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Thu, 30 Jul 2026 10:15:31 +0200 Subject: [PATCH 28/32] Add new_for_test constructor for BrpClient --- src/structs/brp/client.rs | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/structs/brp/client.rs b/src/structs/brp/client.rs index 2d4cf034d..9085dc402 100644 --- a/src/structs/brp/client.rs +++ b/src/structs/brp/client.rs @@ -34,6 +34,16 @@ impl BrpClient { } } + #[cfg(test)] + fn new_for_test() -> Self { + BrpClient::new( + "http://localhost:5010", + "", + "haalcentraal/api/brp/personen", + Duration::from_secs(5), + ) + } + pub async fn get_persons(&self, query: &BrpQuery) -> Result, AppError> { let url = format!("{}/{}", self.base_url, self.persons_endpoint); @@ -259,12 +269,7 @@ mod tests { #[tokio::test] async fn brp_request() { - let brp_client = BrpClient::new( - "http://localhost:5010", - "", - "haalcentraal/api/brp/personen", - Duration::from_secs(30), - ); + let brp_client = BrpClient::new_for_test(); let query = BrpQuery::ConsultWithBsn { bsn: vec!["100600505".parse().unwrap()], fields: vec![BrpField::LastName], @@ -277,12 +282,7 @@ mod tests { #[tokio::test] async fn brp_verify() { - let brp_client = BrpClient::new( - "http://localhost:5010", - "", - "haalcentraal/api/brp/personen", - Duration::from_secs(30), - ); + let brp_client = BrpClient::new_for_test(); let person = sample_person_from_brp(); @@ -299,12 +299,7 @@ mod tests { #[tokio::test] async fn brp_verify_returns_omissions() { - let brp_client = BrpClient::new( - "http://localhost:5010", - "", - "haalcentraal/api/brp/personen", - Duration::from_secs(30), - ); + let brp_client = BrpClient::new_for_test(); let list_id = CandidateListId::new(); let mut person = sample_person(PersonId::new()); @@ -375,12 +370,7 @@ mod tests { #[tokio::test] async fn omission_includes_candidate_lists() { - let brp_client = BrpClient::new( - "http://localhost:5010", - "", - "haalcentraal/api/brp/personen", - Duration::from_secs(30), - ); + let brp_client = BrpClient::new_for_test(); let person = sample_person_from_brp(); let list_id = CandidateListId::new(); From fcfed8a711e53f78b62d7e401ebfc9871689ca65 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Thu, 30 Jul 2026 16:34:43 +0200 Subject: [PATCH 29/32] Convert brp_validation_status from a boolean into an enum --- src/csb/import/pages/import.rs | 33 ++++++++++++++++++++------------- src/csb/store_csb/event.rs | 17 +++++++++-------- src/csb/store_csb/mod.rs | 9 ++++++--- src/structs/brp/mod.rs | 2 ++ src/structs/brp/status.rs | 22 ++++++++++++++++++++++ 5 files changed, 59 insertions(+), 24 deletions(-) create mode 100644 src/structs/brp/status.rs diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index 1eafbef1f..21b2f988e 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -16,7 +16,7 @@ use crate::{ }, filters, redirect_success, store::Store, - structs::brp::BrpClient, + structs::brp::{BrpClient, BrpStatus}, trans, utils::parse_hash_prefix, }; @@ -157,24 +157,23 @@ pub async fn do_brp_verification( store: &Store, brp_client: &BrpClient, ) -> Result<(), AppError> { - let store = store.clone(); - let brp_client = brp_client.clone(); - store - .update(CsbEvent::SetBrpValidationInProgress(true)) + .update(CsbEvent::SetBrpStatus(BrpStatus::InProgress)) .await?; let already_validated = store.get_brp_validations(); - tokio::task::spawn(async move { + let brp_client = brp_client.clone(); + let task_store = store.clone(); + let task = tokio::task::spawn(async move { let mut ticker = tokio::time::interval(BRP_COURTESY_TIMEOUT); - for person in store.get_persons(WithCorrections::None) { + for person in task_store.get_persons(WithCorrections::None) { if already_validated.contains_key(&person.id) { tracing::info!("Person {} has already been validated", person.id); continue; } - let candidate_lists = store + let candidate_lists = task_store .get_candidate_lists(WithCorrections::None) .iter() .filter(|cl| cl.candidates.contains(&person.id)) @@ -187,7 +186,7 @@ pub async fn do_brp_verification( match brp_client.verify(&person, candidate_lists).await { Ok(omissions) => { for omission in omissions { - if let Err(err) = omission.create(&store).await { + if let Err(err) = omission.create(&task_store).await { tracing::error!( "failed to record BRP omission for {}: {err}", person.id @@ -203,17 +202,25 @@ pub async fn do_brp_verification( tracing::info!( "Finished checking candidates on list {:?}", - store.data.read().imported_data.political_group + task_store.data.read().imported_data.political_group ); - if let Err(err) = store - .update(CsbEvent::SetBrpValidationInProgress(false)) + if let Err(e) = task_store + .update(CsbEvent::SetBrpStatus(BrpStatus::Finished)) .await { - tracing::error!("Failed to set BRP validation to false: {err}"); + tracing::error!("Failed to set BRP validation to false: {e}"); } }); + if let Err(e) = task.await + && e.is_panic() + { + store + .update(CsbEvent::SetBrpStatus(BrpStatus::Aborted(e.to_string()))) + .await? + } + Ok(()) } diff --git a/src/csb/store_csb/event.rs b/src/csb/store_csb/event.rs index 6047a6ba9..bb63115de 100644 --- a/src/csb/store_csb/event.rs +++ b/src/csb/store_csb/event.rs @@ -3,7 +3,10 @@ use serde::{Deserialize, Serialize}; use crate::{ Event, PgEvent, PgStoreData, StreamId, persons::PersonId, - structs::csb::{Correction, Omission, OmissionId}, + structs::{ + brp::BrpStatus, + csb::{Correction, Omission, OmissionId}, + }, trans, utils::format_hash, }; @@ -45,7 +48,7 @@ pub enum CsbEvent { person: PersonId, valid: bool, }, - SetBrpValidationInProgress(bool), + SetBrpStatus(BrpStatus), } impl Event for CsbEvent { @@ -59,9 +62,7 @@ impl Event for CsbEvent { | CsbEvent::UpdateOmission(_) | CsbEvent::DeleteOmission { .. } => "omission", CsbEvent::UpdateCorrection(_) => "correction", - CsbEvent::BrpPersonValidated { .. } | CsbEvent::SetBrpValidationInProgress(_) => { - "brp_validation" - } + CsbEvent::BrpPersonValidated { .. } | CsbEvent::SetBrpStatus(_) => "brp_validation", } } @@ -76,7 +77,7 @@ impl Event for CsbEvent { CsbEvent::DeleteOmission { .. } => "delete_omission", CsbEvent::UpdateCorrection(_) => "update_correction", CsbEvent::BrpPersonValidated { .. } => "brp_person_validated", - Self::SetBrpValidationInProgress(_) => "brp_validation", + Self::SetBrpStatus(_) => "brp_validation", } } @@ -93,7 +94,7 @@ impl Event for CsbEvent { trans!("audit_log.event.update_correction", locale) } CsbEvent::BrpPersonValidated { .. } => trans!("audit_log.event.brp_validation", locale), - Self::SetBrpValidationInProgress(_) => { + Self::SetBrpStatus(_) => { trans!("audit_log.event.set_brp_validation_state", locale) } } @@ -118,7 +119,7 @@ impl Event for CsbEvent { CsbEvent::DeleteOmission { omission_id } => omission_id.to_string(), CsbEvent::UpdateCorrection(_) => String::new(), CsbEvent::BrpPersonValidated { person, .. } => person.to_string(), - CsbEvent::SetBrpValidationInProgress(value) => value.to_string(), + CsbEvent::SetBrpStatus(value) => value.to_string(), } } diff --git a/src/csb/store_csb/mod.rs b/src/csb/store_csb/mod.rs index 2852fd21b..28c39b36f 100644 --- a/src/csb/store_csb/mod.rs +++ b/src/csb/store_csb/mod.rs @@ -16,7 +16,10 @@ use crate::{ common::{DisplayName, UtcDateTime}, persons::{Person, PersonId}, store::{StoreData, StoreEvent}, - structs::csb::{Correction, Omission, OmissionId}, + structs::{ + brp::BrpStatus, + csb::{Correction, Omission, OmissionId}, + }, }; /// Event-sourced domain projection for a single (stream, election) pair on the @@ -31,7 +34,7 @@ pub struct CsbStoreData { pub(crate) csb_corrected_persons: HashMap, pub(crate) csb_corrected_display_name: Option, pub(crate) brp_validations: HashMap, - pub(crate) brp_validation_in_progress: bool, + pub(crate) brp_validation_status: BrpStatus, } impl StoreData for CsbStoreData { @@ -113,7 +116,7 @@ impl StoreData for CsbStoreData { CsbEvent::BrpPersonValidated { person, valid } => { self.brp_validations.insert(person, valid); } - CsbEvent::SetBrpValidationInProgress(value) => self.brp_validation_in_progress = value, + CsbEvent::SetBrpStatus(value) => self.brp_validation_status = value, } } diff --git a/src/structs/brp/mod.rs b/src/structs/brp/mod.rs index 1df24c25f..43800111e 100644 --- a/src/structs/brp/mod.rs +++ b/src/structs/brp/mod.rs @@ -1,7 +1,9 @@ mod client; mod field; mod person; +mod status; pub use client::{BRP_PERSONS_ENDPOINT, BRP_TIMEOUT, BrpClient}; pub use field::BrpField; pub use person::BrpPerson; +pub use status::BrpStatus; diff --git a/src/structs/brp/status.rs b/src/structs/brp/status.rs new file mode 100644 index 000000000..a4ac5883a --- /dev/null +++ b/src/structs/brp/status.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; +use std::fmt::Display; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub enum BrpStatus { + #[default] + NotStarted, + InProgress, + Aborted(String), + Finished, +} + +impl Display for BrpStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BrpStatus::NotStarted => write!(f, "not_started"), + BrpStatus::InProgress => write!(f, "in_progress"), + BrpStatus::Aborted(_) => write!(f, "aborted"), + BrpStatus::Finished => write!(f, "finished"), + } + } +} From 69000a8f27763e0b4cecc4334ecbbcbed79acf47 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Fri, 31 Jul 2026 11:27:51 +0200 Subject: [PATCH 30/32] Run BRP checks in a separate background task, that monitors completion --- src/csb/import/pages/import.rs | 208 ++++++++++++++++++++++----------- src/structs/brp/client.rs | 4 +- 2 files changed, 143 insertions(+), 69 deletions(-) diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index 21b2f988e..b6b2c5f48 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -153,6 +153,7 @@ pub async fn create_empty( })) } +/// Performs the BRP validation in a separate task pub async fn do_brp_verification( store: &Store, brp_client: &BrpClient, @@ -161,67 +162,84 @@ pub async fn do_brp_verification( .update(CsbEvent::SetBrpStatus(BrpStatus::InProgress)) .await?; - let already_validated = store.get_brp_validations(); - let brp_client = brp_client.clone(); let task_store = store.clone(); - let task = tokio::task::spawn(async move { - let mut ticker = tokio::time::interval(BRP_COURTESY_TIMEOUT); - for person in task_store.get_persons(WithCorrections::None) { - if already_validated.contains_key(&person.id) { - tracing::info!("Person {} has already been validated", person.id); - continue; - } + tokio::task::spawn(async move { + let task = tokio::task::spawn(verify_candidates(task_store.clone(), brp_client)); + + if let Err(e) = task.await + && e.is_panic() + && let Err(err) = task_store + .update(CsbEvent::SetBrpStatus(BrpStatus::Aborted(e.to_string()))) + .await + { + tracing::error!("Failed to record aborted BRP status: {err}"); + } + }); + + Ok(()) +} - let candidate_lists = task_store - .get_candidate_lists(WithCorrections::None) - .iter() - .filter(|cl| cl.candidates.contains(&person.id)) - .map(|cl| cl.id) - .collect(); - - tracing::info!("Checking person {} against the brp", person.id); - ticker.tick().await; - - match brp_client.verify(&person, candidate_lists).await { - Ok(omissions) => { - for omission in omissions { - if let Err(err) = omission.create(&task_store).await { - tracing::error!( - "failed to record BRP omission for {}: {err}", - person.id - ); - } +/// Check every not-yet-validated candidate on `store` against the BRP, +/// recording an omission for each mismatch and marking the candidate as +/// validated on success. Candidates already present in +/// `CsbStoreData::brp_validations` are skipped, so a later call resumes instead +/// of re-checking everyone. +async fn verify_candidates(store: Store, brp_client: BrpClient) { + let already_validated = store.get_brp_validations(); + let mut ticker = tokio::time::interval(BRP_COURTESY_TIMEOUT); + + for person in store.get_persons(WithCorrections::None) { + if already_validated.contains_key(&person.id) { + tracing::info!("Person {} has already been validated", person.id); + continue; + } + + let candidate_lists = store + .get_candidate_lists(WithCorrections::None) + .iter() + .filter(|cl| cl.candidates.contains(&person.id)) + .map(|cl| cl.id) + .collect(); + + tracing::info!("Checking person {} against the brp", person.id); + ticker.tick().await; + + match brp_client.verify(&person, candidate_lists).await { + Ok(omissions) => { + let valid = omissions.is_empty(); + for omission in omissions { + if let Err(err) = omission.create(&store).await { + tracing::error!("failed to record BRP omission for {}: {err}", person.id); } } - Err(err) => { - tracing::error!("BRP verification failed for {}: {err}", person.id); + if let Err(err) = store + .update(CsbEvent::BrpPersonValidated { + person: person.id, + valid, + }) + .await + { + tracing::error!("failed to record BRP validation for {}: {err}", person.id); } } + Err(_) => { + tracing::error!("BRP verification failed for {}", person.id); + } } + } - tracing::info!( - "Finished checking candidates on list {:?}", - task_store.data.read().imported_data.political_group - ); - - if let Err(e) = task_store - .update(CsbEvent::SetBrpStatus(BrpStatus::Finished)) - .await - { - tracing::error!("Failed to set BRP validation to false: {e}"); - } - }); + tracing::info!( + "Finished checking candidates on list {:?}", + store.data.read().imported_data.political_group + ); - if let Err(e) = task.await - && e.is_panic() + if let Err(e) = store + .update(CsbEvent::SetBrpStatus(BrpStatus::Finished)) + .await { - store - .update(CsbEvent::SetBrpStatus(BrpStatus::Aborted(e.to_string()))) - .await? + tracing::error!("Failed to set BRP validation to false: {e}"); } - - Ok(()) } #[cfg(test)] @@ -231,7 +249,6 @@ mod tests { use crate::{ AppState, CsbContext, ElectionConfig, PgEvent, - structs::brp::BRP_PERSONS_ENDPOINT, test_utils::{response_body_string, sample_person_from_brp}, utils::format_hash, }; @@ -249,6 +266,18 @@ mod tests { Ok((source_stream, format_hash(&hash, false))) } + /// Poll briefly for the background BRP check to finish, instead of + /// sleeping for the full courtesy timeout between candidates. + async fn wait_for_brp_status_finished(store: &Store) { + for _ in 0..100 { + if matches!(store.data.read().brp_validation_status, BrpStatus::Finished) { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("BRP verification did not finish in time"); + } + #[tokio::test] async fn import_renders_placeholder_page() -> Result<(), AppError> { let response = import(CsbImportPath {}, CsbContext::new_test()) @@ -415,25 +444,11 @@ mod tests { person.address.house_number_addition = Some("nope".parse().unwrap()); csb_store.add_person(person); - let brp_client = BrpClient::new( - "http://localhost:5010", - "", - BRP_PERSONS_ENDPOINT, - Duration::from_secs(30), - ); + let brp_client = BrpClient::new_for_test(); do_brp_verification(&csb_store, &brp_client).await?; - // The check runs in a spawned background task; poll briefly for its - // result instead of sleeping for the full courtesy timeout. - let mut omission = None; - for _ in 0..100 { - if let Some(o) = csb_store.data.read().omissions.values().next().cloned() { - omission = Some(o); - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - let omission = omission.expect("BRP verification did not record an omission in time"); + wait_for_brp_status_finished(&csb_store).await; + let omission = csb_store.get_omission_for_test(); assert_eq!( omission.description, "De huisnummertoevoeging komt niet overeen met de BRP" @@ -441,4 +456,63 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn do_brp_verification_returns_without_waiting_for_the_brp_check() -> Result<(), AppError> + { + let state = AppState::new_for_tests().await; + let csb_store = state + .csb_store_for_stream(StreamId::new(), ElectionConfig::EK27) + .await?; + + // Two candidates requires one BRP_COURTESY_TIMEOUT (1s) tick. + // do_brp_verification should return well before that. + csb_store.add_person(sample_person_from_brp()); + csb_store.add_person(sample_person_from_brp()); + + let brp_client = BrpClient::new_for_test(); + + let start = tokio::time::Instant::now(); + do_brp_verification(&csb_store, &brp_client).await?; + let elapsed = start.elapsed(); + assert!( + elapsed < BRP_COURTESY_TIMEOUT, + "do_brp_verification should return immediately instead of waiting for the background check, took {elapsed:?}" + ); + + wait_for_brp_status_finished(&csb_store).await; + + Ok(()) + } + + #[tokio::test] + async fn do_brp_verification_skips_already_validated_persons_on_a_later_call() + -> Result<(), AppError> { + let state = AppState::new_for_tests().await; + let csb_store = state + .csb_store_for_stream(StreamId::new(), ElectionConfig::EK27) + .await?; + + let mut person = sample_person_from_brp(); + person.address.house_number_addition = Some("nope".parse().unwrap()); + csb_store.add_person(person); + + let brp_client = BrpClient::new_for_test(); + + do_brp_verification(&csb_store, &brp_client).await?; + wait_for_brp_status_finished(&csb_store).await; + assert_eq!(csb_store.data.read().omissions.len(), 1); + assert_eq!(csb_store.data.read().brp_validations.len(), 1); + + // Re-running verification should skip the already-validated candidate + // rather than re-checking them and recording a duplicate omission. With + // no candidate left to check, the background task has nothing to wait + // on, so a short fixed delay is enough instead of polling for + // `Finished` (which the first run has already left behind). + do_brp_verification(&csb_store, &brp_client).await?; + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(csb_store.data.read().omissions.len(), 1); + + Ok(()) + } } diff --git a/src/structs/brp/client.rs b/src/structs/brp/client.rs index 9085dc402..380dc17a7 100644 --- a/src/structs/brp/client.rs +++ b/src/structs/brp/client.rs @@ -35,11 +35,11 @@ impl BrpClient { } #[cfg(test)] - fn new_for_test() -> Self { + pub fn new_for_test() -> Self { BrpClient::new( "http://localhost:5010", "", - "haalcentraal/api/brp/personen", + BRP_PERSONS_ENDPOINT, Duration::from_secs(5), ) } From 04ec3235e0c1d9b5c9ef48e29bcd1c516ada5659 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Fri, 31 Jul 2026 14:36:43 +0200 Subject: [PATCH 31/32] Use 'korteStraatnaam' instead of 'officieleStraatnaam' --- src/csb/import/pages/import.rs | 6 +++++- src/structs/brp/client.rs | 2 +- src/structs/brp/field.rs | 3 --- src/structs/brp/person.rs | 5 +---- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index b6b2c5f48..f4c503633 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -202,12 +202,16 @@ async fn verify_candidates(store: Store, brp_client: BrpClient) { .map(|cl| cl.id) .collect(); - tracing::info!("Checking person {} against the brp", person.id); ticker.tick().await; match brp_client.verify(&person, candidate_lists).await { Ok(omissions) => { let valid = omissions.is_empty(); + tracing::info!( + "Checking person {} against the brp {}", + person.id, + if valid { "(valid)" } else { "(invalid)" } + ); for omission in omissions { if let Err(err) = omission.create(&store).await { tracing::error!("failed to record BRP omission for {}: {err}", person.id); diff --git a/src/structs/brp/client.rs b/src/structs/brp/client.rs index 380dc17a7..cca0b0c64 100644 --- a/src/structs/brp/client.rs +++ b/src/structs/brp/client.rs @@ -76,7 +76,7 @@ impl BrpClient { BrpField::Initials, BrpField::LastNamePrefix, BrpField::LastName, - BrpField::OfficialStreetName, + BrpField::StreetName, BrpField::HouseNumber, BrpField::HouseNumberAddition, BrpField::PostalCode, diff --git a/src/structs/brp/field.rs b/src/structs/brp/field.rs index e6da2d897..9f40f19ce 100644 --- a/src/structs/brp/field.rs +++ b/src/structs/brp/field.rs @@ -50,9 +50,6 @@ pub enum BrpField { DateMunicipalRegistration, #[serde(rename = "verblijfplaats.verblijfadres.korteStraatnaam")] StreetName, - // I think this is 'Naam openbare ruimte' - #[serde(rename = "verblijfplaats.verblijfadres.officieleStraatnaam")] - OfficialStreetName, #[serde(rename = "verblijfplaats.verblijfadres.huisnummer")] HouseNumber, #[serde(rename = "verblijfplaats.verblijfadres.huisletter")] diff --git a/src/structs/brp/person.rs b/src/structs/brp/person.rs index b03b1e133..e29276d5c 100644 --- a/src/structs/brp/person.rs +++ b/src/structs/brp/person.rs @@ -49,10 +49,7 @@ structstruck::strike! { Address { #[serde(rename = "verblijfadres")] residence_address: struct BrpAddress { - // TODO: Confirm that this should be officieleStraatnaam - // Or handle this by checking if either matches? If this is only used as a correspondence address, - // then that should be sufficient - #[serde(rename = "officieleStraatnaam")] + #[serde(rename = "korteStraatnaam")] street_name: Option, #[serde(rename = "huisnummer")] house_number: Option, From 22036d318c74c191f735046e1b539df987d09147 Mon Sep 17 00:00:00 2001 From: Stefan van Ieperen Date: Mon, 3 Aug 2026 11:37:11 +0200 Subject: [PATCH 32/32] Improve code structure --- src/csb/import/pages/import.rs | 127 ++++++++++++++++++--------------- src/structs/brp/client.rs | 5 +- src/structs/brp/person.rs | 2 +- 3 files changed, 70 insertions(+), 64 deletions(-) diff --git a/src/csb/import/pages/import.rs b/src/csb/import/pages/import.rs index f4c503633..23511327a 100644 --- a/src/csb/import/pages/import.rs +++ b/src/csb/import/pages/import.rs @@ -14,7 +14,9 @@ use crate::{ WithCorrections, examination::{CsbExaminationOverviewPath, CsbPoliticalGroupPath}, }, - filters, redirect_success, + filters, + persons::Person, + redirect_success, store::Store, structs::brp::{BrpClient, BrpStatus}, trans, @@ -153,7 +155,8 @@ pub async fn create_empty( })) } -/// Performs the BRP validation in a separate task +/// Verifies every candidate against the BRP in a background task. Returns +/// immediately after spawning it instead of waiting for it to finish. pub async fn do_brp_verification( store: &Store, brp_client: &BrpClient, @@ -162,74 +165,53 @@ pub async fn do_brp_verification( .update(CsbEvent::SetBrpStatus(BrpStatus::InProgress)) .await?; - let brp_client = brp_client.clone(); - let task_store = store.clone(); - tokio::task::spawn(async move { - let task = tokio::task::spawn(verify_candidates(task_store.clone(), brp_client)); - - if let Err(e) = task.await - && e.is_panic() - && let Err(err) = task_store - .update(CsbEvent::SetBrpStatus(BrpStatus::Aborted(e.to_string()))) - .await - { - tracing::error!("Failed to record aborted BRP status: {err}"); - } - }); + // Spawned and intentionally not awaited here: verifying every candidate is + // slow, and this must not block the request that triggered it. + tokio::task::spawn(monitor_verification(store.clone(), brp_client.clone())); Ok(()) } -/// Check every not-yet-validated candidate on `store` against the BRP, -/// recording an omission for each mismatch and marking the candidate as -/// validated on success. Candidates already present in -/// `CsbStoreData::brp_validations` are skipped, so a later call resumes instead -/// of re-checking everyone. -async fn verify_candidates(store: Store, brp_client: BrpClient) { +async fn monitor_verification(store: Store, brp_client: BrpClient) { + let outcome = tokio::task::spawn(verify_candidates(store.clone(), brp_client)).await; + + let error = match outcome { + Ok(Ok(())) => return, + Ok(Err(err)) => err.to_string(), + Err(join_err) => join_err.to_string(), + }; + + if let Err(err) = store + .update(CsbEvent::SetBrpStatus(BrpStatus::Aborted(error))) + .await + { + tracing::error!("failed to record aborted BRP status: {err}"); + } +} + +/// Check every not-yet-validated candidate on `store` against the BRP. +/// Candidates already present in `CsbStoreData::brp_validations` are skipped, +/// so a later call resumes instead of re-checking everyone. A single +/// candidate's failure is logged and does not stop the rest of the sweep; only +/// a failure to record the final status is propagated to the caller. +async fn verify_candidates( + store: Store, + brp_client: BrpClient, +) -> Result<(), AppError> { let already_validated = store.get_brp_validations(); let mut ticker = tokio::time::interval(BRP_COURTESY_TIMEOUT); for person in store.get_persons(WithCorrections::None) { if already_validated.contains_key(&person.id) { - tracing::info!("Person {} has already been validated", person.id); + tracing::debug!("Person {} has already been validated", person.id); continue; } - let candidate_lists = store - .get_candidate_lists(WithCorrections::None) - .iter() - .filter(|cl| cl.candidates.contains(&person.id)) - .map(|cl| cl.id) - .collect(); - + tracing::info!("Checking person {} against the brp", person.id); ticker.tick().await; - match brp_client.verify(&person, candidate_lists).await { - Ok(omissions) => { - let valid = omissions.is_empty(); - tracing::info!( - "Checking person {} against the brp {}", - person.id, - if valid { "(valid)" } else { "(invalid)" } - ); - for omission in omissions { - if let Err(err) = omission.create(&store).await { - tracing::error!("failed to record BRP omission for {}: {err}", person.id); - } - } - if let Err(err) = store - .update(CsbEvent::BrpPersonValidated { - person: person.id, - valid, - }) - .await - { - tracing::error!("failed to record BRP validation for {}: {err}", person.id); - } - } - Err(_) => { - tracing::error!("BRP verification failed for {}", person.id); - } + if let Err(err) = verify_candidate(&store, &brp_client, &person).await { + tracing::error!("BRP verification failed for {}: {err}", person.id); } } @@ -238,12 +220,39 @@ async fn verify_candidates(store: Store, brp_client: BrpClient) { store.data.read().imported_data.political_group ); - if let Err(e) = store + store .update(CsbEvent::SetBrpStatus(BrpStatus::Finished)) .await - { - tracing::error!("Failed to set BRP validation to false: {e}"); +} + +/// Verify this candidate against the BRP, creating omissions that contain the +/// lists this candidate is on. Finally, the store is updated with a +/// `BrpPersonValidated` event. +async fn verify_candidate( + store: &Store, + brp_client: &BrpClient, + person: &Person, +) -> Result<(), AppError> { + let candidate_lists = store + .get_candidate_lists(WithCorrections::None) + .iter() + .filter(|cl| cl.candidates.contains(&person.id)) + .map(|cl| cl.id) + .collect(); + + let omissions = brp_client.verify(person, candidate_lists).await?; + let valid = omissions.is_empty(); + + for omission in omissions { + omission.create(store).await?; } + + store + .update(CsbEvent::BrpPersonValidated { + person: person.id, + valid, + }) + .await } #[cfg(test)] diff --git a/src/structs/brp/client.rs b/src/structs/brp/client.rs index cca0b0c64..0328f1307 100644 --- a/src/structs/brp/client.rs +++ b/src/structs/brp/client.rs @@ -93,10 +93,7 @@ impl BrpClient { } None => { // TODO: This needs to be implemented - tracing::warn!( - "Person {} does not have a BSN filled in (or none confirmed)", - person.id, - ); + tracing::warn!("Person {} does not have a BSN filled in", person.id,); return Err(AppError::GenericNotFound); } }; diff --git a/src/structs/brp/person.rs b/src/structs/brp/person.rs index e29276d5c..d803f53b0 100644 --- a/src/structs/brp/person.rs +++ b/src/structs/brp/person.rs @@ -128,7 +128,7 @@ impl From for BrpPerson { Some(BrpPlaceOfResidence::NonDutchAddress) => { // TODO: How to handle this? Set the address to None and conduct an additional BRP check // for the Authorised Person? - tracing::error!("Person has an non-Dutch address"); + tracing::warn!("Person has an non-Dutch address"); (None, None) } None => {