decision_cockpit 0.1.0

Layer — product decision memory with MCP tools and an embedded review dashboard
Documentation
//! Relations connect canonical entities in the decision graph. Without a
//! relation, an accepted assumption/action/evidence is orphaned and never shows
//! up under a decision, so this is the glue that makes the graph meaningful.

use sqlx::PgPool;
use uuid::Uuid;

use crate::db::repositories::{
    actions as actions_repo, assumptions as assumptions_repo, decisions as decisions_repo,
    drift as drift_repo, evidence as evidence_repo, memos as memos_repo, relations as repo,
};
use crate::domain::relations::EntityRelation;
use crate::domain::{EntityType, RelationType};
use crate::error::{AppError, AppResult};

pub struct NewRelationInput {
    pub from_entity_id: Uuid,
    pub from_entity_type: EntityType,
    pub to_entity_id: Uuid,
    pub to_entity_type: EntityType,
    pub relation_type: RelationType,
}

pub async fn create_relation(
    pool: &PgPool,
    input: NewRelationInput,
) -> AppResult<EntityRelation> {
    if input.from_entity_id == input.to_entity_id {
        return Err(AppError::Validation(
            "a relation must connect two different entities".into(),
        ));
    }
    ensure_entity_exists(pool, input.from_entity_type, input.from_entity_id).await?;
    ensure_entity_exists(pool, input.to_entity_type, input.to_entity_id).await?;

    Ok(repo::create(
        pool,
        repo::NewRelation {
            from_entity_id: input.from_entity_id,
            from_entity_type: input.from_entity_type.as_str(),
            to_entity_id: input.to_entity_id,
            to_entity_type: input.to_entity_type.as_str(),
            relation_type: input.relation_type.as_str(),
        },
    )
    .await?)
}

/// The natural relation between a decision and a freshly-accepted entity of the
/// given type. Used to auto-link candidates that carry `relates_to_decision_id`.
pub fn default_relation_for(entity_type: EntityType) -> RelationType {
    match entity_type {
        EntityType::Assumption => RelationType::DependsOn,
        EntityType::Action => RelationType::Produces,
        EntityType::Evidence => RelationType::Supports,
        _ => RelationType::RelatedTo,
    }
}

pub async fn ensure_entity_exists(
    pool: &PgPool,
    entity_type: EntityType,
    entity_id: Uuid,
) -> AppResult<()> {
    let exists = match entity_type {
        EntityType::Decision => decisions_repo::get(pool, entity_id).await?.is_some(),
        EntityType::Assumption => assumptions_repo::get(pool, entity_id).await?.is_some(),
        EntityType::Action => actions_repo::get(pool, entity_id).await?.is_some(),
        EntityType::Evidence => evidence_repo::get(pool, entity_id).await?.is_some(),
        EntityType::DriftSignal => drift_repo::get(pool, entity_id).await?.is_some(),
        EntityType::Memo => memos_repo::get(pool, entity_id).await?.is_some(),
    };

    if exists {
        Ok(())
    } else {
        Err(AppError::NotFound(format!(
            "{} {entity_id} not found",
            entity_type.as_str()
        )))
    }
}