ceres-server 0.3.0

REST API server for Ceres semantic search
//! Portal management endpoints.

use axum::{
    Json,
    extract::{Path, State},
    http::StatusCode,
};

use ceres_core::{CreateJobRequest, JobQueue, PortalType};

use crate::dto::{
    HarvestJobResponse, PortalInfoResponse, PortalStatsResponse, TriggerHarvestRequest,
};
use crate::error::ApiError;
use crate::state::AppState;

/// List all configured portals with sync status.
#[utoipa::path(
    get,
    path = "/api/v1/portals",
    responses(
        (status = 200, description = "List of portals", body = Vec<PortalInfoResponse>),
        (status = 500, description = "Internal server error"),
    ),
    tag = "portals"
)]
pub async fn list_portals(
    State(state): State<AppState>,
) -> Result<Json<Vec<PortalInfoResponse>>, ApiError> {
    let Some(config) = &state.portals_config else {
        return Ok(Json(vec![]));
    };

    let mut portals = Vec::new();

    for portal in &config.portals {
        // TODO(correctness): .ok().flatten() silences DB errors — log or propagate (#108)
        let sync_status = state
            .dataset_repo
            .get_sync_status(&portal.url)
            .await
            .ok()
            .flatten();

        portals.push(PortalInfoResponse {
            name: portal.name.clone(),
            url: portal.url.clone(),
            portal_type: portal.portal_type.to_string(),
            enabled: portal.enabled,
            description: portal.description.clone(),
            last_sync: sync_status.as_ref().and_then(|s| s.last_successful_sync),
            dataset_count: sync_status.map(|s| s.datasets_synced as i64),
        });
    }

    Ok(Json(portals))
}

/// Get statistics for a specific portal.
#[utoipa::path(
    get,
    path = "/api/v1/portals/{name}/stats",
    params(
        ("name" = String, Path, description = "Portal name")
    ),
    responses(
        (status = 200, description = "Portal statistics", body = PortalStatsResponse),
        (status = 404, description = "Portal not found"),
        (status = 500, description = "Internal server error"),
    ),
    tag = "portals"
)]
pub async fn get_portal_stats(
    State(state): State<AppState>,
    Path(name): Path<String>,
) -> Result<Json<PortalStatsResponse>, ApiError> {
    let Some(config) = &state.portals_config else {
        return Err(ApiError::NotFound("No portals configured".to_string()));
    };

    let portal = config
        .find_by_name(&name)
        .ok_or_else(|| ApiError::NotFound(format!("Portal not found: {}", name)))?;

    // Get sync status
    let sync_status = state
        .dataset_repo
        .get_sync_status(&portal.url)
        .await
        .map_err(ApiError::from)?;

    let (last_sync, last_sync_mode, last_sync_status, last_sync_datasets) =
        if let Some(status) = sync_status {
            (
                status.last_successful_sync,
                status.last_sync_mode,
                status.sync_status,
                Some(status.datasets_synced),
            )
        } else {
            (None, None, None, None)
        };

    Ok(Json(PortalStatsResponse {
        name: portal.name.clone(),
        url: portal.url.clone(),
        dataset_count: last_sync_datasets.unwrap_or(0) as i64,
        last_sync,
        last_sync_mode,
        last_sync_status,
        last_sync_datasets,
    }))
}

/// Trigger harvest for a specific portal.
///
/// Creates a harvest job and returns immediately with the job ID.
/// Use GET /api/v1/harvest/status to check progress.
#[utoipa::path(
    post,
    path = "/api/v1/portals/{name}/harvest",
    params(
        ("name" = String, Path, description = "Portal name")
    ),
    request_body = TriggerHarvestRequest,
    responses(
        (status = 202, description = "Harvest job created", body = HarvestJobResponse),
        (status = 401, description = "Unauthorized"),
        (status = 403, description = "Forbidden (admin endpoints disabled)"),
        (status = 404, description = "Portal not found"),
        (status = 500, description = "Internal server error"),
    ),
    security(("bearer" = [])),
    tag = "portals"
)]
pub async fn trigger_portal_harvest(
    State(state): State<AppState>,
    Path(name): Path<String>,
    Json(request): Json<TriggerHarvestRequest>,
) -> Result<(StatusCode, Json<HarvestJobResponse>), ApiError> {
    let Some(config) = &state.portals_config else {
        return Err(ApiError::NotFound("No portals configured".to_string()));
    };

    let portal = config
        .find_by_name(&name)
        .ok_or_else(|| ApiError::NotFound(format!("Portal not found: {}", name)))?;

    // Only CKAN portals are supported for job-based harvesting for now
    if portal.portal_type != PortalType::Ckan {
        return Err(ApiError::BadRequest(format!(
            "Portal type '{}' is not yet supported for job-based harvesting. Only 'ckan' is currently implemented.",
            portal.portal_type
        )));
    }

    let mut job_request = CreateJobRequest::new(&portal.url).with_name(&portal.name);

    if let Some(ref tmpl) = portal.url_template {
        job_request = job_request.with_url_template(tmpl);
    }

    if let Some(ref lang) = portal.language {
        job_request = job_request.with_language(lang);
    }

    if request.force_full_sync {
        job_request = job_request.with_full_sync();
    }

    let job = state
        .job_repo
        .create_job(job_request)
        .await
        .map_err(ApiError::from)?;

    Ok((StatusCode::ACCEPTED, Json(HarvestJobResponse::from(job))))
}