use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::domain::{error::DppError, passport::PassportId};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegistryIdentifiers {
pub product_id: String,
pub operator_id: String,
pub facility_id: String,
pub registry_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegistrationRequest {
pub passport_id: PassportId,
pub operator_identifier: String,
pub facility_identifier: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub facility: Option<crate::domain::passport::FacilitySnapshot>,
pub product_category: String,
pub data_carrier_uri: String,
pub schema_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jws_signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub country_code: String,
}
impl RegistrationRequest {
pub fn from_published_passport(
passport: &crate::domain::passport::Passport,
country_code: &str,
) -> Self {
let product_category = serde_json::to_value(&passport.sector)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default();
Self {
passport_id: passport.id,
operator_identifier: passport.operator_identifier.clone().unwrap_or_default(),
facility_identifier: passport
.facility
.as_ref()
.map(|f| f.value.clone())
.unwrap_or_default(),
facility: passport.facility.clone(),
product_category,
data_carrier_uri: passport.qr_code_url.clone().unwrap_or_default(),
schema_version: passport.schema_version.clone(),
jws_signature: passport.jws_signature.clone(),
published_at: passport.published_at,
country_code: country_code.to_owned(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum RegistryStatus {
Pending,
Registered,
Rejected,
Transferred,
SuspendedByAuthority,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegistryRecord {
pub identifiers: RegistryIdentifiers,
pub status: RegistryStatus,
pub registered_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[async_trait]
pub trait RegistrySyncPort: Send + Sync {
async fn register(&self, request: RegistrationRequest) -> Result<RegistryRecord, DppError>;
async fn check_status(&self, passport_id: PassportId) -> Result<RegistryRecord, DppError>;
async fn notify_transfer(
&self,
passport_id: PassportId,
new_operator_identifier: String,
) -> Result<RegistryRecord, DppError>;
}
pub use crate::ports::ghosts::GhostRegistrySync;
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{
passport::{ManufacturerInfo, Passport, PassportId},
sector::Sector,
status::PassportStatus,
};
use chrono::Utc;
fn make_published_passport() -> Passport {
Passport {
id: PassportId::new(),
batch_id: None,
product_name: "Test".into(),
sector: Sector::Textile,
product_category: None,
manufacturer: ManufacturerInfo {
name: "ACME".into(),
address: "Berlin".into(),
did_web_url: None,
},
materials: vec![],
co2e_per_unit: None,
repairability_score: None,
compliance_result: None,
lint_result: None,
sector_data: None,
status: PassportStatus::Published,
qr_code_url: Some("https://id.odal-node.io/01/09506000134352".into()),
jws_signature: Some("eyJ0eXAiOiJKV1QifQ.payload.sig".into()),
public_jws_signature: None,
created_at: Utc::now(),
updated_at: Utc::now(),
published_at: Some(Utc::now()),
schema_version: "1.1.0".into(),
retention_locked: true,
version: 1,
supersedes_id: None,
parent_passport_ref: None,
component_refs: Vec::new(),
retention_until: None,
product_id: None,
operator_identifier: Some("did:web:acme.example.com".into()),
facility: Some(crate::domain::passport::FacilitySnapshot {
scheme: "national".into(),
value: "FAC-DE-001".into(),
name: "Acme Plant".into(),
country: "DE".into(),
address: None,
}),
seal: None,
}
}
#[test]
fn from_published_passport_maps_all_fields() {
let passport = make_published_passport();
let req = RegistrationRequest::from_published_passport(&passport, "DE");
assert_eq!(req.passport_id, passport.id);
assert_eq!(req.operator_identifier, "did:web:acme.example.com");
assert_eq!(req.facility_identifier, "FAC-DE-001");
assert_eq!(
req.facility.as_ref().map(|f| f.name.as_str()),
Some("Acme Plant")
);
assert_eq!(
req.facility.as_ref().map(|f| f.country.as_str()),
Some("DE")
);
assert_eq!(req.product_category, "textile");
assert_eq!(
req.data_carrier_uri,
"https://id.odal-node.io/01/09506000134352"
);
assert_eq!(req.schema_version, "1.1.0");
assert!(req.jws_signature.is_some());
assert!(req.published_at.is_some());
assert_eq!(req.country_code, "DE");
}
#[test]
fn from_published_passport_empty_optionals_produce_empty_strings() {
let mut passport = make_published_passport();
passport.operator_identifier = None;
passport.facility = None;
passport.qr_code_url = None;
let req = RegistrationRequest::from_published_passport(&passport, "");
assert!(req.operator_identifier.is_empty());
assert!(req.facility_identifier.is_empty());
assert!(req.facility.is_none());
assert!(req.data_carrier_uri.is_empty());
assert!(req.country_code.is_empty());
}
#[test]
fn registry_status_serde_round_trip() {
let statuses = vec![
RegistryStatus::Pending,
RegistryStatus::Registered,
RegistryStatus::Rejected,
RegistryStatus::Transferred,
RegistryStatus::SuspendedByAuthority,
];
for status in statuses {
let json = serde_json::to_string(&status).unwrap();
let back: RegistryStatus = serde_json::from_str(&json).unwrap();
assert_eq!(status, back);
}
}
}