use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use cron::Schedule;
use mockforge_registry_core::models::{CloudWorkspace, TestSchedule, TestSuite};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use uuid::Uuid;
use crate::{
error::{ApiError, ApiResult},
middleware::{resolve_org_context, AuthUser},
AppState,
};
#[derive(Debug, Deserialize)]
pub struct CreateScheduleRequest {
pub cron: String,
#[serde(default = "default_tz")]
pub timezone: String,
}
fn default_tz() -> String {
"UTC".to_string()
}
#[derive(Debug, Serialize)]
pub struct ScheduleWithNextFire {
#[serde(flatten)]
pub schedule: TestSchedule,
pub next_fire_at: Option<DateTime<Utc>>,
}
fn compute_next_fire(schedule: &TestSchedule) -> Option<DateTime<Utc>> {
if !schedule.enabled {
return None;
}
let tz: Tz = schedule.timezone.parse().ok()?;
let cron = Schedule::from_str(&schedule.cron).ok()?;
cron.upcoming(tz).next().map(|dt| dt.with_timezone(&Utc))
}
pub async fn list_for_suite(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(suite_id): Path<Uuid>,
headers: HeaderMap,
) -> ApiResult<Json<Vec<ScheduleWithNextFire>>> {
let _suite = load_authorized_suite(&state, user_id, &headers, suite_id).await?;
let rows = TestSchedule::list_by_suite(state.db.pool(), suite_id)
.await
.map_err(ApiError::Database)?;
let with_next = rows
.into_iter()
.map(|s| ScheduleWithNextFire {
next_fire_at: compute_next_fire(&s),
schedule: s,
})
.collect();
Ok(Json(with_next))
}
pub async fn create(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(suite_id): Path<Uuid>,
headers: HeaderMap,
Json(body): Json<CreateScheduleRequest>,
) -> ApiResult<Json<TestSchedule>> {
let _suite = load_authorized_suite(&state, user_id, &headers, suite_id).await?;
if body.cron.trim().is_empty() {
return Err(ApiError::InvalidRequest("cron must not be empty".into()));
}
if Schedule::from_str(&body.cron).is_err() {
return Err(ApiError::InvalidRequest("cron expression is invalid".into()));
}
if body.timezone.parse::<Tz>().is_err() {
return Err(ApiError::InvalidRequest("timezone is not an IANA name".into()));
}
let row = TestSchedule::create(state.db.pool(), suite_id, &body.cron, &body.timezone)
.await
.map_err(ApiError::Database)?;
Ok(Json(row))
}
#[derive(Debug, Deserialize)]
pub struct UpdateScheduleRequest {
pub enabled: bool,
}
pub async fn set_enabled(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(id): Path<Uuid>,
headers: HeaderMap,
Json(body): Json<UpdateScheduleRequest>,
) -> ApiResult<Json<TestSchedule>> {
let pool = state.db.pool();
let existing = sqlx::query_as::<_, TestSchedule>("SELECT * FROM test_schedules WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
.map_err(ApiError::Database)?
.ok_or_else(|| ApiError::InvalidRequest("Test schedule not found".into()))?;
let _suite = load_authorized_suite(&state, user_id, &headers, existing.suite_id).await?;
let updated = TestSchedule::set_enabled(pool, id, body.enabled)
.await
.map_err(ApiError::Database)?
.ok_or_else(|| ApiError::InvalidRequest("Test schedule not found".into()))?;
Ok(Json(updated))
}
pub async fn delete(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(id): Path<Uuid>,
headers: HeaderMap,
) -> ApiResult<Json<serde_json::Value>> {
let pool = state.db.pool();
let existing = sqlx::query_as::<_, TestSchedule>("SELECT * FROM test_schedules WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
.map_err(ApiError::Database)?
.ok_or_else(|| ApiError::InvalidRequest("Test schedule not found".into()))?;
let _suite = load_authorized_suite(&state, user_id, &headers, existing.suite_id).await?;
let deleted = TestSchedule::delete(pool, id).await.map_err(ApiError::Database)?;
if !deleted {
return Err(ApiError::InvalidRequest("Test schedule not found".into()));
}
Ok(Json(serde_json::json!({ "deleted": true })))
}
async fn load_authorized_suite(
state: &AppState,
user_id: Uuid,
headers: &HeaderMap,
suite_id: Uuid,
) -> ApiResult<TestSuite> {
let suite = TestSuite::find_by_id(state.db.pool(), suite_id)
.await
.map_err(ApiError::Database)?
.ok_or_else(|| ApiError::InvalidRequest("Test suite not found".into()))?;
let workspace = CloudWorkspace::find_by_id(state.db.pool(), suite.workspace_id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("Workspace not found".into()))?;
let ctx = resolve_org_context(state, user_id, headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".into()))?;
if ctx.org_id != workspace.org_id {
return Err(ApiError::InvalidRequest("Test suite not found".into()));
}
Ok(suite)
}