backbone-catalog 0.7.0

Canonical product/service identity: Item, Item Group, UOM (Indonesia-first)
Documentation
//! Catalog Module
//!
//! Generated by metaphor-schema. Enhanced with runtime implementations.
//!
//! This module provides:
//! - Domain entities and repositories
//! - Application services
//! - HTTP and gRPC handlers
//! - Route configuration
//! - State machine enforcement
//! - Validation rules runtime
//! - RBAC middleware
//! - Trigger execution system
//! - Computed fields
//! - Workflow orchestrator

#![recursion_limit = "1024"]
#![allow(unused_imports)]

// Generated modules
pub mod domain;
pub mod infrastructure;
pub mod application;
pub mod presentation;
pub mod seeders;

// <<< CUSTOM
// metaphor-schema generates these files on disk but does not declare them in lib.rs
// (generator gap). Declared here inside a CUSTOM block so regeneration preserves the wiring.
pub mod handlers;   // AppState DI container (from_module / builder)
pub mod routes;     // stateless + stateful route composers over CatalogModule
pub mod exports;    // public cross-module API surface (DTOs, query service, events)
// END CUSTOM
// Internal use only — NOT re-exported. The crate root no longer dumps every entity/repository:
// consumers depend on `catalog::exports` (the DTO/query-service contract) or explicit paths like
// `catalog::domain::entity::Item`. Contract-seat finding (semver-breaking: catalog::Item etc. no
// longer resolve at the root). These private `use`s keep build()'s unqualified repository names.
use domain::entity::*;
use infrastructure::persistence::*;

// Re-exports - Application services
pub use application::service::AttributeService;
pub use application::service::AttributeValueService;
pub use application::service::BrandService;
pub use application::service::ItemService;
pub use application::service::ItemGroupService;
pub use application::service::ItemVariantService;
pub use application::service::UomService;
pub use application::service::UomConversionService;

// <<< CUSTOM
pub use application::service::{
    CatalogWriteError, CatalogWriteService, NewAttribute, NewAttributeValue, NewBrand, NewItem,
    NewItemGroup, NewItemVariant, NewUom,
};
// UoM parent-store tree conversion surface (ADR-0023).
pub use domain::services::uom_tree::{
    convert_quantity, ConversionRounding, UomChain, UomChainNode, UomConversionError,
};
pub use presentation::http::create_guarded_catalog_routes;
// END CUSTOM
use std::sync::Arc;
use axum::Router;
use sqlx::PgPool;

/// Fail-fast guard for row-level-security posture (ADR-0029).
///
/// The catalog tables ship with `ROW LEVEL SECURITY` enabled and forced but with no policies of
/// their own — the composing service's tenancy decorator installs the org-scoped policies. A
/// **superuser** connection bypasses RLS entirely, which would defeat whatever fence the
/// composing host deployed. Call this once at startup (before serving) to refuse to run if the
/// pool connects as a superuser; connect the application role as a non-superuser
/// (migrations/seeders may still run as the owner).
pub async fn assert_rls_enforced(pool: &PgPool) -> anyhow::Result<()> {
    let is_super: bool = sqlx::query_scalar("SELECT current_setting('is_superuser')::boolean")
        .fetch_one(pool)
        .await?;
    if is_super {
        anyhow::bail!(
            "backbone-catalog RLS guard: the database connection is a SUPERUSER, which bypasses \
             ROW LEVEL SECURITY and defeats the composing host's org fence. Connect the app as \
             a non-superuser role (migrations/seeders may still run as the owner)."
        );
    }
    Ok(())
}

/// Catalog module configuration
///
/// Use the builder pattern to configure and register this module. **For any real
/// deployment, mount the guarded router** — read-only base + validated writes via
/// `CatalogWriteService`. The unguarded full-CRUD surface (`all_crud_routes`) is
/// gated behind the default-off `unguarded` cargo feature, for trusted/admin/seeding
/// use only.
///
/// ```text
/// let catalog = CatalogModule::builder()
///     .with_database(pool.clone())
///     .build()?;
///
/// // Production: validated writes + read-only base (the default, safe surface).
/// let router = create_guarded_catalog_routes(&catalog);
///
/// // Trusted/admin/seeding only — opt in with `--features unguarded`:
/// // let admin = catalog.all_crud_routes();
/// ```
pub struct CatalogModule {
    pub(crate) attribute_service: Arc<AttributeService>,
    pub(crate) attribute_value_service: Arc<AttributeValueService>,
    pub(crate) brand_service: Arc<BrandService>,
    pub(crate) item_service: Arc<ItemService>,
    pub(crate) item_group_service: Arc<ItemGroupService>,
    pub(crate) item_variant_service: Arc<ItemVariantService>,
    pub(crate) uom_service: Arc<UomService>,
    pub(crate) uom_conversion_service: Arc<UomConversionService>,
    // <<< CUSTOM
    /// Validated Item/ItemGroup/UoM-tree writes (FK existence, usage flags, tree links,
    /// stored-factor re-derivation).
    pub(crate) catalog_write_service: Arc<CatalogWriteService>,
    // END CUSTOM
}

impl CatalogModule {
    /// Create a new module builder
    pub fn builder() -> CatalogModuleBuilder {
        CatalogModuleBuilder::new()
    }

    /// Mount ALL generated CRUD endpoints (12 per entity) with NO domain
    /// validation — the fully **unguarded** surface. A well-formed request can
    /// create invalid rows or soft-delete a referenced master out from under its
    /// dependents. Prefer a guarded composition (read + validated writes) for any
    /// real deployment; use this only in trusted/admin/seeding contexts.
    #[cfg(any(test, feature = "unguarded"))]
    pub fn all_crud_routes(&self) -> Router {
        use presentation::http::{
            create_attribute_routes,
            create_attribute_value_routes,
            create_brand_routes,
            create_item_routes,
            create_item_group_routes,
            create_item_variant_routes,
            create_uom_routes,
            create_uom_conversion_routes,
        };

        Router::new()
            .merge(create_attribute_routes(self.attribute_service.clone()))
            .merge(create_attribute_value_routes(self.attribute_value_service.clone()))
            .merge(create_brand_routes(self.brand_service.clone()))
            .merge(create_item_routes(self.item_service.clone()))
            .merge(create_item_group_routes(self.item_group_service.clone()))
            .merge(create_item_variant_routes(self.item_variant_service.clone()))
            .merge(create_uom_routes(self.uom_service.clone()))
            .merge(create_uom_conversion_routes(self.uom_conversion_service.clone()))
    }

    /// Deprecated alias for [`Self::all_crud_routes`]. `routes()` reads like
    /// "the routes" but mounts UNVALIDATED generic CRUD on every entity — a naive
    /// mount exposes unguarded writes. Compose a guarded router (read + validated
    /// writes) for production, or call `all_crud_routes()` to opt into the full
    /// unguarded surface explicitly.
    #[cfg(any(test, feature = "unguarded"))]
    #[deprecated(note = "mounts unvalidated generic CRUD on every entity; compose a guarded router for production, or call all_crud_routes() for the intentional full/unguarded surface")]
    pub fn routes(&self) -> Router {
        self.all_crud_routes()
    }
}

/// Builder for CatalogModule
pub struct CatalogModuleBuilder {
    db_pool: Option<PgPool>,
}

impl CatalogModuleBuilder {
    /// Create a new builder
    pub fn new() -> Self {
        Self {
            db_pool: None,
        }
    }

    /// Set the database connection pool
    pub fn with_database(mut self, pool: PgPool) -> Self {
        self.db_pool = Some(pool);
        self
    }

    // <<< CUSTOM - custom builder methods
    // END CUSTOM

    /// Build the module with configured dependencies
    pub fn build(self) -> anyhow::Result<CatalogModule> {
        let db_pool = self.db_pool
            .ok_or_else(|| anyhow::anyhow!("Database pool not configured"))?;

        // Attribute service
        let attribute_repository = Arc::new(AttributeRepository::new(db_pool.clone()));
        let attribute_service = Arc::new(AttributeService::with_repository(attribute_repository.clone()));

        // AttributeValue service
        let attribute_value_repository = Arc::new(AttributeValueRepository::new(db_pool.clone()));
        let attribute_value_service = Arc::new(AttributeValueService::with_repository(attribute_value_repository.clone()));

        // Brand service
        let brand_repository = Arc::new(BrandRepository::new(db_pool.clone()));
        let brand_service = Arc::new(BrandService::with_repository(brand_repository.clone()));

        // Item service
        let item_repository = Arc::new(ItemRepository::new(db_pool.clone()));
        let item_service = Arc::new(ItemService::with_repository(item_repository.clone()));

        // ItemGroup service
        let item_group_repository = Arc::new(ItemGroupRepository::new(db_pool.clone()));
        let item_group_service = Arc::new(ItemGroupService::with_repository(item_group_repository.clone()));

        // ItemVariant service
        let item_variant_repository = Arc::new(ItemVariantRepository::new(db_pool.clone()));
        let item_variant_service = Arc::new(ItemVariantService::with_repository(item_variant_repository.clone()));

        // Uom service
        let uom_repository = Arc::new(UomRepository::new(db_pool.clone()));
        let uom_service = Arc::new(UomService::with_repository(uom_repository.clone()));

        // UomConversion service
        let uom_conversion_repository = Arc::new(UomConversionRepository::new(db_pool.clone()));
        let uom_conversion_service = Arc::new(UomConversionService::with_repository(uom_conversion_repository.clone()));

        // <<< CUSTOM
        let catalog_write_service = Arc::new(CatalogWriteService::new(db_pool.clone()));
        // END CUSTOM

        Ok(CatalogModule {
            attribute_service,
            attribute_value_service,
            brand_service,
            item_service,
            item_group_service,
            item_variant_service,
            uom_service,
            uom_conversion_service,
            // <<< CUSTOM
            catalog_write_service,
            // END CUSTOM
        })
    }
}

impl Default for CatalogModuleBuilder {
    fn default() -> Self {
        Self::new()
    }
}