systemprompt-api 0.2.0

HTTP API server and gateway for systemprompt.io OS
Documentation
use super::types::{AnalyticsQuery, GenerateLinkRequest, GenerateLinkResponse, ListLinksQuery};
use axum::extract::{Path, Query, State};
use axum::response::{IntoResponse, Redirect};
use axum::{Extension, Json};
use systemprompt_content::{LinkAnalyticsService, LinkGenerationService};
use systemprompt_database::DbPool;
use systemprompt_identifiers::{CampaignId, ContentId, LinkId, SessionId};
use systemprompt_models::{ApiError, Config, RequestContext};
use systemprompt_runtime::AppContext;
use tracing::error;

pub async fn redirect_handler(
    State(db_pool): State<DbPool>,
    Extension(req_ctx): Extension<RequestContext>,
    Path(short_code): Path<String>,
) -> impl IntoResponse {
    let link_gen_service = match LinkGenerationService::new(&db_pool) {
        Ok(s) => s,
        Err(e) => return ApiError::internal_error(e.to_string()).into_response(),
    };
    let analytics_service = match LinkAnalyticsService::new(&db_pool) {
        Ok(s) => s,
        Err(e) => return ApiError::internal_error(e.to_string()).into_response(),
    };

    let link = match link_gen_service.get_link_by_short_code(&short_code).await {
        Ok(Some(link)) => link,
        Ok(None) => {
            return ApiError::not_found("Link not found").into_response();
        },
        Err(e) => return ApiError::internal_error(e.to_string()).into_response(),
    };

    let track_params = systemprompt_content::TrackClickParams::new(
        link.id.clone(),
        SessionId::new(req_ctx.request.session_id.as_str()),
    )
    .with_user_id(Some(req_ctx.auth.user_id.clone()))
    .with_context_id(Some(req_ctx.execution.context_id.clone()))
    .with_task_id(req_ctx.execution.task_id.clone());

    if !req_ctx.request.session_id.as_str().starts_with("bot_") {
        if let Err(e) = analytics_service.track_click(&track_params).await {
            error!(link_id = %link.id, error = %e, "Failed to track click");
        }
    }

    let target_url = link.get_full_url();
    Redirect::temporary(&target_url).into_response()
}

pub async fn generate_link_handler(
    State(ctx): State<AppContext>,
    Extension(_req_ctx): Extension<RequestContext>,
    Json(payload): Json<GenerateLinkRequest>,
) -> impl IntoResponse {
    let link_type = match payload.link_type.as_str() {
        "redirect" => systemprompt_content::LinkType::Redirect,
        "utm" => systemprompt_content::LinkType::Utm,
        "both" => systemprompt_content::LinkType::Both,
        _ => {
            return ApiError::bad_request(
                "Invalid link_type. Must be 'redirect', 'utm', or 'both'",
            )
            .into_response();
        },
    };

    let utm_params = if payload.utm_source.is_some()
        || payload.utm_medium.is_some()
        || payload.utm_campaign.is_some()
    {
        Some(systemprompt_content::UtmParams {
            source: payload.utm_source,
            medium: payload.utm_medium,
            campaign: payload.utm_campaign,
            term: payload.utm_term,
            content: payload.utm_content,
        })
    } else {
        None
    };

    let link_gen_service = match LinkGenerationService::new(ctx.db_pool()) {
        Ok(s) => s,
        Err(e) => return ApiError::internal_error(e.to_string()).into_response(),
    };

    let campaign_id = payload.campaign_id.map(CampaignId::new);
    let source_content_id = payload.source_content_id.map(ContentId::new);

    match link_gen_service
        .generate_link(systemprompt_content::GenerateLinkParams {
            target_url: payload.target_url.clone(),
            link_type,
            campaign_id,
            campaign_name: payload.campaign_name,
            source_content_id,
            source_page: payload.source_page,
            utm_params,
            link_text: payload.link_text,
            link_position: payload.link_position,
            expires_at: payload.expires_at,
        })
        .await
    {
        Ok(link) => {
            let base_url = match Config::get() {
                Ok(c) => c.api_external_url.clone(),
                Err(e) => {
                    return ApiError::internal_error(format!("Configuration unavailable: {e}"))
                        .into_response();
                },
            };
            let redirect_url = LinkGenerationService::build_trackable_url(&link, &base_url);
            let full_url = link.get_full_url();

            Json(GenerateLinkResponse {
                link_id: link.id,
                short_code: link.short_code,
                redirect_url,
                full_url,
            })
            .into_response()
        },
        Err(e) => ApiError::internal_error(e.to_string()).into_response(),
    }
}

pub async fn get_link_performance_handler(
    State(ctx): State<AppContext>,
    Extension(_req_ctx): Extension<RequestContext>,
    Path(link_id): Path<String>,
) -> impl IntoResponse {
    let analytics_service = match LinkAnalyticsService::new(ctx.db_pool()) {
        Ok(s) => s,
        Err(e) => return ApiError::internal_error(e.to_string()).into_response(),
    };

    let link_id = LinkId::new(link_id);
    match analytics_service.get_link_performance(&link_id).await {
        Ok(Some(performance)) => Json(performance).into_response(),
        Ok(None) => ApiError::not_found("Link not found").into_response(),
        Err(e) => ApiError::internal_error(e.to_string()).into_response(),
    }
}

pub async fn get_campaign_performance_handler(
    State(ctx): State<AppContext>,
    Extension(_req_ctx): Extension<RequestContext>,
    Path(campaign_id): Path<String>,
) -> impl IntoResponse {
    let analytics_service = match LinkAnalyticsService::new(ctx.db_pool()) {
        Ok(s) => s,
        Err(e) => return ApiError::internal_error(e.to_string()).into_response(),
    };

    let campaign_id = CampaignId::new(campaign_id);
    match analytics_service
        .get_campaign_performance(&campaign_id)
        .await
    {
        Ok(Some(performance)) => Json(performance).into_response(),
        Ok(None) => ApiError::not_found("Campaign not found").into_response(),
        Err(e) => ApiError::internal_error(e.to_string()).into_response(),
    }
}

pub async fn get_content_journey_handler(
    State(ctx): State<AppContext>,
    Extension(_req_ctx): Extension<RequestContext>,
    Query(query): Query<AnalyticsQuery>,
) -> impl IntoResponse {
    let analytics_service = match LinkAnalyticsService::new(ctx.db_pool()) {
        Ok(s) => s,
        Err(e) => return ApiError::internal_error(e.to_string()).into_response(),
    };

    match analytics_service
        .get_content_journey_map(query.limit, query.offset)
        .await
    {
        Ok(journey) => Json(journey).into_response(),
        Err(e) => ApiError::internal_error(e.to_string()).into_response(),
    }
}

pub async fn list_links_handler(
    State(ctx): State<AppContext>,
    Extension(_req_ctx): Extension<RequestContext>,
    Query(query): Query<ListLinksQuery>,
) -> impl IntoResponse {
    let analytics_service = match LinkAnalyticsService::new(ctx.db_pool()) {
        Ok(s) => s,
        Err(e) => return ApiError::internal_error(e.to_string()).into_response(),
    };

    if let Some(campaign_id) = query.campaign_id {
        let campaign_id = CampaignId::new(campaign_id);
        match analytics_service.get_links_by_campaign(&campaign_id).await {
            Ok(links) => Json(links).into_response(),
            Err(e) => ApiError::internal_error(e.to_string()).into_response(),
        }
    } else if let Some(source_content_id) = query.source_content_id {
        let source_content_id = ContentId::new(source_content_id);
        match analytics_service
            .get_links_by_source_content(&source_content_id)
            .await
        {
            Ok(links) => Json(links).into_response(),
            Err(e) => ApiError::internal_error(e.to_string()).into_response(),
        }
    } else {
        ApiError::bad_request("Must provide either campaign_id or source_content_id")
            .into_response()
    }
}

pub async fn get_link_clicks_handler(
    State(ctx): State<AppContext>,
    Extension(_req_ctx): Extension<RequestContext>,
    Path(link_id): Path<String>,
    Query(query): Query<AnalyticsQuery>,
) -> impl IntoResponse {
    let analytics_service = match LinkAnalyticsService::new(ctx.db_pool()) {
        Ok(s) => s,
        Err(e) => return ApiError::internal_error(e.to_string()).into_response(),
    };

    let link_id = LinkId::new(link_id);
    match analytics_service
        .get_link_clicks(&link_id, query.limit, query.offset)
        .await
    {
        Ok(clicks) => Json(clicks).into_response(),
        Err(e) => ApiError::internal_error(e.to_string()).into_response(),
    }
}