feat: add integrations

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-08 23:00:14 +01:00
parent 5a5f9a3003
commit 646581ff44
65 changed files with 7774 additions and 127 deletions

View File

@@ -1,26 +1,32 @@
mod auth;
mod forest_client;
mod notification_consumer;
mod notification_ingester;
mod notification_worker;
mod routes;
mod serve_http;
mod session_reaper;
mod state;
mod templates;
use std::net::SocketAddr;
use std::sync::Arc;
use axum::Router;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use forage_core::session::{FileSessionStore, SessionStore};
use forage_db::PgSessionStore;
use minijinja::context;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use opentelemetry::trace::TracerProvider as _;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use axum::Router;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use minijinja::context;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use crate::forest_client::GrpcForestClient;
use crate::state::AppState;
use crate::templates::TemplateEngine;
@@ -31,7 +37,6 @@ fn init_telemetry() {
let fmt_layer = tracing_subscriber::fmt::layer();
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok() {
// OTLP exporter configured — send spans + logs to collector
let tracer = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.build()
@@ -104,61 +109,127 @@ async fn main() -> anyhow::Result<()> {
let forest_client = GrpcForestClient::connect_lazy(&forest_endpoint)?;
let template_engine = TemplateEngine::new()?;
// Session store: PostgreSQL if DATABASE_URL is set, otherwise in-memory
let sessions: Arc<dyn SessionStore> = if let Ok(database_url) = std::env::var("DATABASE_URL") {
tracing::info!("using PostgreSQL session store");
let pool = sqlx::PgPool::connect(&database_url).await?;
forage_db::migrate(&pool).await?;
let pg_store = Arc::new(PgSessionStore::new(pool));
// Session reaper for PostgreSQL
let reaper = pg_store.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
interval.tick().await;
match reaper.reap_expired(30).await {
Ok(n) if n > 0 => tracing::info!("session reaper: removed {n} expired sessions"),
Err(e) => tracing::warn!("session reaper error: {e}"),
_ => {}
}
}
});
pg_store
} else {
let session_dir = std::env::var("SESSION_DIR").unwrap_or_else(|_| "target/sessions".into());
tracing::info!("using file session store at {session_dir} (set DATABASE_URL for PostgreSQL)");
let file_store = Arc::new(FileSessionStore::new(&session_dir).expect("failed to create session dir"));
let reaper = file_store.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
interval.tick().await;
reaper.reap_expired();
tracing::debug!("session reaper: {} active sessions", reaper.session_count());
}
});
file_store
};
let forest_client = Arc::new(forest_client);
let state = AppState::new(template_engine, forest_client.clone(), forest_client.clone(), sessions)
.with_grpc_client(forest_client);
let app = build_router(state);
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
// Build components based on available configuration
let mut mad = notmad::Mad::builder();
// Session store + integration store: PostgreSQL if DATABASE_URL is set
let (sessions, integration_store): (Arc<dyn SessionStore>, Option<Arc<dyn forage_core::integrations::IntegrationStore>>);
if let Ok(database_url) = std::env::var("DATABASE_URL") {
tracing::info!("using PostgreSQL session store");
let pool = sqlx::PgPool::connect(&database_url).await?;
forage_db::migrate(&pool).await?;
let pg_store = Arc::new(PgSessionStore::new(pool.clone()));
// Integration store (uses same pool)
let encryption_key = std::env::var("INTEGRATION_ENCRYPTION_KEY")
.unwrap_or_else(|_| {
tracing::warn!("INTEGRATION_ENCRYPTION_KEY not set — using default key (not safe for production)");
"forage-dev-key-not-for-production!!".to_string()
});
let pg_integrations = Arc::new(forage_db::PgIntegrationStore::new(pool, encryption_key.into_bytes()));
// Session reaper component
mad.add(session_reaper::PgSessionReaper {
store: pg_store.clone(),
max_inactive_days: 30,
});
sessions = pg_store;
integration_store = Some(pg_integrations as Arc<dyn forage_core::integrations::IntegrationStore>);
} else {
let session_dir = std::env::var("SESSION_DIR").unwrap_or_else(|_| "target/sessions".into());
tracing::info!("using file session store at {session_dir} (set DATABASE_URL for PostgreSQL)");
let file_store = Arc::new(FileSessionStore::new(&session_dir).expect("failed to create session dir"));
// File session reaper component
mad.add(session_reaper::FileSessionReaper {
store: file_store.clone(),
});
sessions = file_store as Arc<dyn SessionStore>;
integration_store = None;
};
let forest_client = Arc::new(forest_client);
let mut state = AppState::new(template_engine, forest_client.clone(), forest_client.clone(), sessions)
.with_grpc_client(forest_client.clone());
// Slack OAuth config (optional, enables "Add to Slack" button)
if let (Ok(client_id), Ok(client_secret)) = (
std::env::var("SLACK_CLIENT_ID"),
std::env::var("SLACK_CLIENT_SECRET"),
) {
let base_url = std::env::var("FORAGE_BASE_URL")
.unwrap_or_else(|_| format!("http://localhost:{port}"));
tracing::info!("Slack OAuth enabled");
state = state.with_slack_config(crate::state::SlackConfig {
client_id,
client_secret,
base_url,
});
}
// NATS JetStream connection (optional, enables durable notification delivery)
let nats_jetstream = if let Ok(nats_url) = std::env::var("NATS_URL") {
match async_nats::connect(&nats_url).await {
Ok(client) => {
tracing::info!("connected to NATS at {nats_url}");
Some(async_nats::jetstream::new(client))
}
Err(e) => {
tracing::error!(error = %e, "failed to connect to NATS — falling back to direct dispatch");
None
}
}
} else {
None
};
if let Some(ref store) = integration_store {
state = state.with_integration_store(store.clone());
if let Ok(service_token) = std::env::var("FORAGE_SERVICE_TOKEN") {
if let Some(ref js) = nats_jetstream {
// JetStream mode: ingester publishes, consumer dispatches
tracing::info!("starting notification pipeline (JetStream)");
mad.add(notification_ingester::NotificationIngester {
grpc: forest_client,
jetstream: js.clone(),
service_token,
});
mad.add(notification_consumer::NotificationConsumer {
jetstream: js.clone(),
store: store.clone(),
});
} else {
// Fallback: direct dispatch (no durability)
tracing::warn!("NATS_URL not set — using direct notification dispatch (no durability)");
mad.add(notification_worker::NotificationListener {
grpc: forest_client,
store: store.clone(),
service_token,
});
}
} else {
tracing::warn!("FORAGE_SERVICE_TOKEN not set — notification listener disabled");
}
}
// HTTP server component
mad.add(serve_http::ServeHttp {
addr,
state,
});
mad.run().await?;
Ok(())
}