decision_cockpit 0.1.0

Layer — product decision memory with MCP tools and an embedded review dashboard
Documentation
use sqlx::PgPool;
use uuid::Uuid;

use crate::db::repositories::documents as repo;
use crate::domain::documents::{Document, DocumentSummary};
use crate::error::{AppError, AppResult};

const DEFAULT_LIST_LIMIT: i64 = 20;

/// Allowed processing states for a document.
pub const DOCUMENT_STATUSES: [&str; 3] = ["new", "extracted", "archived"];

pub async fn create_document(
    pool: &PgPool,
    title: &str,
    source_type: &str,
    raw_text: &str,
) -> AppResult<Document> {
    let title = title.trim();
    let source_type = source_type.trim();
    if title.is_empty() {
        return Err(AppError::Validation("title must not be empty".into()));
    }
    if source_type.is_empty() {
        return Err(AppError::Validation("source_type must not be empty".into()));
    }
    if raw_text.trim().is_empty() {
        return Err(AppError::Validation("raw_text must not be empty".into()));
    }

    Ok(repo::create(pool, title, source_type, raw_text).await?)
}

pub async fn get_document(pool: &PgPool, id: Uuid) -> AppResult<Document> {
    repo::get(pool, id)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("document {id} not found")))
}

pub async fn list_recent_documents(
    pool: &PgPool,
    status: Option<&str>,
    limit: Option<i64>,
) -> AppResult<Vec<DocumentSummary>> {
    if let Some(s) = status {
        validate_status(s)?;
    }
    let limit = normalize_limit(limit);
    Ok(repo::list_recent(pool, status, limit).await?)
}

/// Set a document's processing status (`new` / `extracted` / `archived`).
pub async fn set_document_status(
    pool: &PgPool,
    id: Uuid,
    status: &str,
) -> AppResult<Document> {
    validate_status(status)?;
    repo::update_status(pool, id, status)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("document {id} not found")))
}

/// Mark a document as `extracted` if it is still `new`. Best-effort: used after
/// the first candidate is created so already-mined docs drop out of the queue.
pub async fn mark_extracted_if_new(pool: &PgPool, id: Uuid) -> AppResult<()> {
    let doc = repo::get(pool, id).await?;
    if let Some(doc) = doc {
        if doc.status == "new" {
            repo::update_status(pool, id, "extracted").await?;
        }
    }
    Ok(())
}

fn validate_status(status: &str) -> AppResult<()> {
    if DOCUMENT_STATUSES.contains(&status) {
        Ok(())
    } else {
        Err(AppError::Validation(format!(
            "invalid document status `{status}`; expected one of: {}",
            DOCUMENT_STATUSES.join(", ")
        )))
    }
}

/// Clamp a caller-provided limit into a sane range.
pub fn normalize_limit(limit: Option<i64>) -> i64 {
    limit.unwrap_or(DEFAULT_LIST_LIMIT).clamp(1, 200)
}