use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::domain::{
identity::AccessTier,
sector::{CarbonFootprint, RepairabilityScore, Sector, SectorData},
status::PassportStatus,
};
use crate::ports::compliance::ComplianceResult;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PassportId(pub Uuid);
impl PassportId {
#[must_use]
pub fn new() -> Self {
Self(Uuid::now_v7())
}
}
impl Default for PassportId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for PassportId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProductCategory {
EvBattery,
IndustrialBattery,
LmtBattery,
Apparel,
Footwear,
HomeTextile,
Smartphone,
Laptop,
Charger,
Other(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ManufacturerInfo {
pub name: String,
pub address: String,
pub did_web_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MaterialEntry {
pub name: String,
pub weight_kg: f64,
pub recycled_pct: Option<f64>,
pub origin_country: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Passport {
pub id: PassportId,
pub batch_id: Option<String>,
pub product_name: String,
pub sector: Sector,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product_category: Option<ProductCategory>,
pub manufacturer: ManufacturerInfo,
pub materials: Vec<MaterialEntry>,
pub co2e_per_unit: Option<CarbonFootprint>,
pub repairability_score: Option<RepairabilityScore>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compliance_result: Option<ComplianceResult>,
pub sector_data: Option<SectorData>,
pub status: PassportStatus,
pub qr_code_url: Option<String>,
pub jws_signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_jws_signature: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub published_at: Option<DateTime<Utc>>,
pub schema_version: String,
#[serde(default)]
pub retention_locked: bool,
#[serde(default = "default_version")]
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes_id: Option<PassportId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention_until: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator_identifier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub facility_id: Option<String>,
}
fn default_version() -> u32 {
1
}
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct PassportView(pub serde_json::Value);
impl PassportView {
pub fn into_value(self) -> serde_json::Value {
self.0
}
}
impl Passport {
pub fn validate(&self) -> Result<(), crate::domain::error::DppError> {
let mut issues = Vec::new();
if self.product_name.trim().is_empty() {
issues.push("product_name must not be empty");
}
if self.manufacturer.name.trim().is_empty() {
issues.push("manufacturer.name must not be empty");
}
if self.manufacturer.address.trim().is_empty() {
issues.push("manufacturer.address must not be empty");
}
let sv = &self.schema_version;
let parts: Vec<&str> = sv.split('.').collect();
if parts.len() < 3
|| !parts[0].chars().all(|c| c.is_ascii_digit())
|| !parts[1].chars().all(|c| c.is_ascii_digit())
|| parts[2].is_empty()
{
issues.push("schema_version must be valid semver (e.g. 1.0.0)");
}
if let Some(ref cf) = self.co2e_per_unit
&& cf.value_kg < 0.0
{
issues.push("co2e_per_unit must not be negative");
}
if let Some(ref rs) = self.repairability_score
&& !(0.0..=10.0).contains(&rs.overall)
{
issues.push("repairability_score must be between 0.0 and 10.0");
}
if let Some(ref data) = self.sector_data
&& data.sector() != self.sector
{
issues.push("sector must match sector_data's sector");
}
let structural_err = if issues.is_empty() {
None
} else {
Some(issues.join("; "))
};
#[cfg(not(target_arch = "wasm32"))]
let sector_err: Option<String> = if let Some(ref data) = self.sector_data {
crate::domain::validation::validate_sector_data(data)
.err()
.map(|ve| ve.to_display())
} else {
None
};
#[cfg(target_arch = "wasm32")]
let sector_err: Option<String> = None;
match (structural_err, sector_err) {
(None, None) => Ok(()),
(Some(s), None) | (None, Some(s)) => {
Err(crate::domain::error::DppError::Validation(s.into()))
}
(Some(a), Some(b)) => Err(crate::domain::error::DppError::Validation(
format!("{a}; {b}").into(),
)),
}
}
pub fn transition_to(
&mut self,
next: PassportStatus,
) -> Result<(), crate::domain::error::DppError> {
if !self.status.can_transition_to(&next) {
return Err(crate::domain::error::DppError::InvalidTransition {
current: self.status.to_string(),
required: next.to_string(),
});
}
let now = chrono::Utc::now();
if next == PassportStatus::Published && self.published_at.is_none() {
self.retention_locked = true;
self.published_at = Some(now);
}
self.status = next;
self.updated_at = now;
Ok(())
}
pub fn redact(
&self,
viewer_tier: AccessTier,
catalog: &crate::catalog::SectorCatalog,
) -> PassportView {
let mut value = match serde_json::to_value(self) {
Ok(v) => v,
Err(_) => return PassportView(serde_json::Value::Null),
};
if let Some(obj) = value.as_object_mut() {
if viewer_tier < AccessTier::Professional {
obj.remove("batchId");
}
if viewer_tier < AccessTier::Confidential {
obj.remove("jwsSignature");
obj.remove("retentionLocked");
}
if let Some(ref sd) = self.sector_data {
let key = sd.sector().catalog_key();
let redacted = if let Some(descriptor) = catalog.get(key) {
crate::domain::sector::redact_sector_data(sd, viewer_tier, descriptor)
} else {
serde_json::to_value(sd).unwrap_or(serde_json::Value::Null)
};
obj.insert("sectorData".into(), redacted);
} else {
obj.remove("sectorData");
}
}
PassportView(value)
}
}