Skip to main content

backbone_catalog/
lib.rs

1//! Catalog Module
2//!
3//! Generated by metaphor-schema. Enhanced with runtime implementations.
4//!
5//! This module provides:
6//! - Domain entities and repositories
7//! - Application services
8//! - HTTP and gRPC handlers
9//! - Route configuration
10//! - State machine enforcement
11//! - Validation rules runtime
12//! - RBAC middleware
13//! - Trigger execution system
14//! - Computed fields
15//! - Workflow orchestrator
16
17#![recursion_limit = "1024"]
18#![allow(unused_imports)]
19
20// Generated modules
21pub mod domain;
22pub mod infrastructure;
23pub mod application;
24pub mod presentation;
25pub mod seeders;
26
27// <<< CUSTOM
28// metaphor-schema generates these files on disk but does not declare them in lib.rs
29// (generator gap). Declared here inside a CUSTOM block so regeneration preserves the wiring.
30pub mod handlers;   // AppState DI container (from_module / builder)
31pub mod routes;     // stateless + stateful route composers over CatalogModule
32pub mod exports;    // public cross-module API surface (DTOs, query service, events)
33// END CUSTOM
34// Internal use only — NOT re-exported. The crate root no longer dumps every entity/repository:
35// consumers depend on `catalog::exports` (the DTO/query-service contract) or explicit paths like
36// `catalog::domain::entity::Item`. Contract-seat finding (semver-breaking: catalog::Item etc. no
37// longer resolve at the root). These private `use`s keep build()'s unqualified repository names.
38use domain::entity::*;
39use infrastructure::persistence::*;
40
41// Re-exports - Application services
42pub use application::service::AttributeService;
43pub use application::service::AttributeValueService;
44pub use application::service::BrandService;
45pub use application::service::ItemService;
46pub use application::service::ItemGroupService;
47pub use application::service::ItemVariantService;
48pub use application::service::UomService;
49pub use application::service::UomConversionService;
50
51// <<< CUSTOM
52pub use application::service::{
53    CatalogWriteError, CatalogWriteService, NewAttribute, NewAttributeValue, NewBrand, NewItem,
54    NewItemGroup, NewItemVariant, NewUom,
55};
56// UoM parent-store tree conversion surface (ADR-0023).
57pub use domain::services::uom_tree::{
58    convert_quantity, ConversionRounding, UomChain, UomChainNode, UomConversionError,
59};
60pub use presentation::http::create_guarded_catalog_routes;
61// END CUSTOM
62use std::sync::Arc;
63use axum::Router;
64use sqlx::PgPool;
65
66/// Fail-fast guard for row-level-security posture (ADR-0029).
67///
68/// The catalog tables ship with `ROW LEVEL SECURITY` enabled and forced but with no policies of
69/// their own — the composing service's tenancy decorator installs the org-scoped policies. A
70/// **superuser** connection bypasses RLS entirely, which would defeat whatever fence the
71/// composing host deployed. Call this once at startup (before serving) to refuse to run if the
72/// pool connects as a superuser; connect the application role as a non-superuser
73/// (migrations/seeders may still run as the owner).
74pub async fn assert_rls_enforced(pool: &PgPool) -> anyhow::Result<()> {
75    let is_super: bool = sqlx::query_scalar("SELECT current_setting('is_superuser')::boolean")
76        .fetch_one(pool)
77        .await?;
78    if is_super {
79        anyhow::bail!(
80            "backbone-catalog RLS guard: the database connection is a SUPERUSER, which bypasses \
81             ROW LEVEL SECURITY and defeats the composing host's org fence. Connect the app as \
82             a non-superuser role (migrations/seeders may still run as the owner)."
83        );
84    }
85    Ok(())
86}
87
88/// Catalog module configuration
89///
90/// Use the builder pattern to configure and register this module. **For any real
91/// deployment, mount the guarded router** — read-only base + validated writes via
92/// `CatalogWriteService`. The unguarded full-CRUD surface (`all_crud_routes`) is
93/// gated behind the default-off `unguarded` cargo feature, for trusted/admin/seeding
94/// use only.
95///
96/// ```text
97/// let catalog = CatalogModule::builder()
98///     .with_database(pool.clone())
99///     .build()?;
100///
101/// // Production: validated writes + read-only base (the default, safe surface).
102/// let router = create_guarded_catalog_routes(&catalog);
103///
104/// // Trusted/admin/seeding only — opt in with `--features unguarded`:
105/// // let admin = catalog.all_crud_routes();
106/// ```
107pub struct CatalogModule {
108    pub(crate) attribute_service: Arc<AttributeService>,
109    pub(crate) attribute_value_service: Arc<AttributeValueService>,
110    pub(crate) brand_service: Arc<BrandService>,
111    pub(crate) item_service: Arc<ItemService>,
112    pub(crate) item_group_service: Arc<ItemGroupService>,
113    pub(crate) item_variant_service: Arc<ItemVariantService>,
114    pub(crate) uom_service: Arc<UomService>,
115    pub(crate) uom_conversion_service: Arc<UomConversionService>,
116    // <<< CUSTOM
117    /// Validated Item/ItemGroup/UoM-tree writes (FK existence, usage flags, tree links,
118    /// stored-factor re-derivation).
119    pub(crate) catalog_write_service: Arc<CatalogWriteService>,
120    // END CUSTOM
121}
122
123impl CatalogModule {
124    /// Create a new module builder
125    pub fn builder() -> CatalogModuleBuilder {
126        CatalogModuleBuilder::new()
127    }
128
129    /// Mount ALL generated CRUD endpoints (12 per entity) with NO domain
130    /// validation — the fully **unguarded** surface. A well-formed request can
131    /// create invalid rows or soft-delete a referenced master out from under its
132    /// dependents. Prefer a guarded composition (read + validated writes) for any
133    /// real deployment; use this only in trusted/admin/seeding contexts.
134    #[cfg(any(test, feature = "unguarded"))]
135    pub fn all_crud_routes(&self) -> Router {
136        use presentation::http::{
137            create_attribute_routes,
138            create_attribute_value_routes,
139            create_brand_routes,
140            create_item_routes,
141            create_item_group_routes,
142            create_item_variant_routes,
143            create_uom_routes,
144            create_uom_conversion_routes,
145        };
146
147        Router::new()
148            .merge(create_attribute_routes(self.attribute_service.clone()))
149            .merge(create_attribute_value_routes(self.attribute_value_service.clone()))
150            .merge(create_brand_routes(self.brand_service.clone()))
151            .merge(create_item_routes(self.item_service.clone()))
152            .merge(create_item_group_routes(self.item_group_service.clone()))
153            .merge(create_item_variant_routes(self.item_variant_service.clone()))
154            .merge(create_uom_routes(self.uom_service.clone()))
155            .merge(create_uom_conversion_routes(self.uom_conversion_service.clone()))
156    }
157
158    /// Deprecated alias for [`Self::all_crud_routes`]. `routes()` reads like
159    /// "the routes" but mounts UNVALIDATED generic CRUD on every entity — a naive
160    /// mount exposes unguarded writes. Compose a guarded router (read + validated
161    /// writes) for production, or call `all_crud_routes()` to opt into the full
162    /// unguarded surface explicitly.
163    #[cfg(any(test, feature = "unguarded"))]
164    #[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")]
165    pub fn routes(&self) -> Router {
166        self.all_crud_routes()
167    }
168}
169
170/// Builder for CatalogModule
171pub struct CatalogModuleBuilder {
172    db_pool: Option<PgPool>,
173}
174
175impl CatalogModuleBuilder {
176    /// Create a new builder
177    pub fn new() -> Self {
178        Self {
179            db_pool: None,
180        }
181    }
182
183    /// Set the database connection pool
184    pub fn with_database(mut self, pool: PgPool) -> Self {
185        self.db_pool = Some(pool);
186        self
187    }
188
189    // <<< CUSTOM - custom builder methods
190    // END CUSTOM
191
192    /// Build the module with configured dependencies
193    pub fn build(self) -> anyhow::Result<CatalogModule> {
194        let db_pool = self.db_pool
195            .ok_or_else(|| anyhow::anyhow!("Database pool not configured"))?;
196
197        // Attribute service
198        let attribute_repository = Arc::new(AttributeRepository::new(db_pool.clone()));
199        let attribute_service = Arc::new(AttributeService::with_repository(attribute_repository.clone()));
200
201        // AttributeValue service
202        let attribute_value_repository = Arc::new(AttributeValueRepository::new(db_pool.clone()));
203        let attribute_value_service = Arc::new(AttributeValueService::with_repository(attribute_value_repository.clone()));
204
205        // Brand service
206        let brand_repository = Arc::new(BrandRepository::new(db_pool.clone()));
207        let brand_service = Arc::new(BrandService::with_repository(brand_repository.clone()));
208
209        // Item service
210        let item_repository = Arc::new(ItemRepository::new(db_pool.clone()));
211        let item_service = Arc::new(ItemService::with_repository(item_repository.clone()));
212
213        // ItemGroup service
214        let item_group_repository = Arc::new(ItemGroupRepository::new(db_pool.clone()));
215        let item_group_service = Arc::new(ItemGroupService::with_repository(item_group_repository.clone()));
216
217        // ItemVariant service
218        let item_variant_repository = Arc::new(ItemVariantRepository::new(db_pool.clone()));
219        let item_variant_service = Arc::new(ItemVariantService::with_repository(item_variant_repository.clone()));
220
221        // Uom service
222        let uom_repository = Arc::new(UomRepository::new(db_pool.clone()));
223        let uom_service = Arc::new(UomService::with_repository(uom_repository.clone()));
224
225        // UomConversion service
226        let uom_conversion_repository = Arc::new(UomConversionRepository::new(db_pool.clone()));
227        let uom_conversion_service = Arc::new(UomConversionService::with_repository(uom_conversion_repository.clone()));
228
229        // <<< CUSTOM
230        let catalog_write_service = Arc::new(CatalogWriteService::new(db_pool.clone()));
231        // END CUSTOM
232
233        Ok(CatalogModule {
234            attribute_service,
235            attribute_value_service,
236            brand_service,
237            item_service,
238            item_group_service,
239            item_variant_service,
240            uom_service,
241            uom_conversion_service,
242            // <<< CUSTOM
243            catalog_write_service,
244            // END CUSTOM
245        })
246    }
247}
248
249impl Default for CatalogModuleBuilder {
250    fn default() -> Self {
251        Self::new()
252    }
253}