use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use mockforge_registry_core::models::notification_channel::CreateNotificationChannel;
use serde::Deserialize;
use uuid::Uuid;
use crate::{
error::{ApiError, ApiResult},
middleware::{resolve_org_context, AuthUser},
models::NotificationChannel,
AppState,
};
const VALID_KINDS: &[&str] = &["email", "slack", "pagerduty", "webhook"];
pub async fn list_channels(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(org_id): Path<Uuid>,
headers: HeaderMap,
) -> ApiResult<Json<Vec<NotificationChannel>>> {
authorize_org(&state, user_id, &headers, org_id).await?;
let channels = NotificationChannel::list_by_org(state.db.pool(), org_id)
.await
.map_err(ApiError::Database)?;
Ok(Json(channels))
}
#[derive(Debug, Deserialize)]
pub struct CreateChannelRequest {
pub name: String,
pub kind: String,
pub config: serde_json::Value,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
fn default_enabled() -> bool {
true
}
pub async fn create_channel(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(org_id): Path<Uuid>,
headers: HeaderMap,
Json(request): Json<CreateChannelRequest>,
) -> ApiResult<Json<NotificationChannel>> {
authorize_org(&state, user_id, &headers, org_id).await?;
if request.name.trim().is_empty() {
return Err(ApiError::InvalidRequest("name must not be empty".into()));
}
if !VALID_KINDS.contains(&request.kind.as_str()) {
return Err(ApiError::InvalidRequest(format!(
"kind must be one of: {}",
VALID_KINDS.join(", ")
)));
}
let channel = NotificationChannel::create(
state.db.pool(),
CreateNotificationChannel {
org_id,
name: &request.name,
kind: &request.kind,
config: &request.config,
enabled: request.enabled,
},
)
.await
.map_err(ApiError::Database)?;
Ok(Json(channel))
}
#[derive(Debug, Deserialize)]
pub struct UpdateChannelRequest {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub config: Option<serde_json::Value>,
#[serde(default)]
pub enabled: Option<bool>,
}
pub async fn update_channel(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path((org_id, id)): Path<(Uuid, Uuid)>,
headers: HeaderMap,
Json(request): Json<UpdateChannelRequest>,
) -> ApiResult<Json<NotificationChannel>> {
authorize_org(&state, user_id, &headers, org_id).await?;
let existing = load_authorized_channel(&state, org_id, id).await?;
let _ = existing;
let updated = NotificationChannel::update(
state.db.pool(),
id,
request.name.as_deref(),
request.config.as_ref(),
request.enabled,
)
.await
.map_err(ApiError::Database)?
.ok_or_else(|| ApiError::InvalidRequest("Notification channel not found".into()))?;
Ok(Json(updated))
}
pub async fn test_fire_channel(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path((org_id, id)): Path<(Uuid, Uuid)>,
headers: HeaderMap,
) -> ApiResult<Json<serde_json::Value>> {
authorize_org(&state, user_id, &headers, org_id).await?;
let channel = load_authorized_channel(&state, org_id, id).await?;
if !channel.enabled {
return Err(ApiError::InvalidRequest("Channel is disabled — enable it first".into()));
}
let result = crate::workers::incident_dispatcher::test_fire(&channel).await;
Ok(Json(result))
}
pub async fn delete_channel(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path((org_id, id)): Path<(Uuid, Uuid)>,
headers: HeaderMap,
) -> ApiResult<Json<serde_json::Value>> {
authorize_org(&state, user_id, &headers, org_id).await?;
load_authorized_channel(&state, org_id, id).await?;
let deleted = NotificationChannel::delete(state.db.pool(), id)
.await
.map_err(ApiError::Database)?;
if !deleted {
return Err(ApiError::InvalidRequest("Notification channel not found".into()));
}
Ok(Json(serde_json::json!({ "deleted": true })))
}
async fn authorize_org(
state: &AppState,
user_id: Uuid,
headers: &HeaderMap,
org_id: Uuid,
) -> ApiResult<()> {
let ctx = resolve_org_context(state, user_id, headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".into()))?;
if ctx.org_id != org_id {
return Err(ApiError::InvalidRequest("Cannot access channels for a different org".into()));
}
Ok(())
}
async fn load_authorized_channel(
state: &AppState,
org_id: Uuid,
id: Uuid,
) -> ApiResult<NotificationChannel> {
let channel = NotificationChannel::find_by_id(state.db.pool(), id)
.await
.map_err(ApiError::Database)?
.ok_or_else(|| ApiError::InvalidRequest("Notification channel not found".into()))?;
if channel.org_id != org_id {
return Err(ApiError::InvalidRequest("Notification channel not found".into()));
}
Ok(channel)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_kinds_are_recognized() {
assert!(VALID_KINDS.contains(&"email"));
assert!(VALID_KINDS.contains(&"slack"));
assert!(VALID_KINDS.contains(&"pagerduty"));
assert!(VALID_KINDS.contains(&"webhook"));
}
#[test]
fn unknown_kinds_are_rejected_in_validation() {
assert!(!VALID_KINDS.contains(&"sms"));
assert!(!VALID_KINDS.contains(&""));
assert!(!VALID_KINDS.contains(&"SLACK"));
}
}