feat: schedule history — list, detail, rollback endpoints

This commit is contained in:
2026-03-17 14:38:51 +01:00
parent c0fb8f69de
commit ad3a73f061
4 changed files with 157 additions and 1 deletions

View File

@@ -180,6 +180,34 @@ impl From<domain::Channel> for ChannelResponse {
}
}
// ============================================================================
// Config history DTOs
// ============================================================================
#[derive(Debug, Serialize)]
pub struct ConfigSnapshotResponse {
pub id: Uuid,
pub version_num: i64,
pub label: Option<String>,
pub created_at: DateTime<Utc>,
}
impl From<domain::ChannelConfigSnapshot> for ConfigSnapshotResponse {
fn from(s: domain::ChannelConfigSnapshot) -> Self {
Self {
id: s.id,
version_num: s.version_num,
label: s.label,
created_at: s.created_at,
}
}
}
#[derive(Debug, Deserialize)]
pub struct PatchSnapshotRequest {
pub label: Option<String>,
}
// ============================================================================
// EPG / playback DTOs
// ============================================================================
@@ -305,6 +333,27 @@ pub struct TranscodeStatsResponse {
pub item_count: usize,
}
#[derive(Debug, Serialize)]
pub struct ScheduleHistoryEntry {
pub id: Uuid,
pub generation: u32,
pub valid_from: DateTime<Utc>,
pub valid_until: DateTime<Utc>,
pub slot_count: usize,
}
impl From<domain::GeneratedSchedule> for ScheduleHistoryEntry {
fn from(s: domain::GeneratedSchedule) -> Self {
Self {
id: s.id,
generation: s.generation,
valid_from: s.valid_from,
valid_until: s.valid_until,
slot_count: s.slots.len(),
}
}
}
impl From<domain::GeneratedSchedule> for ScheduleResponse {
fn from(s: domain::GeneratedSchedule) -> Self {
Self {

View File

@@ -27,6 +27,15 @@ pub fn router() -> Router<AppState> {
"/{id}/schedule",
post(schedule::generate_schedule).get(schedule::get_active_schedule),
)
.route("/{id}/schedule/history", get(schedule::list_schedule_history))
.route(
"/{id}/schedule/history/{gen_id}",
get(schedule::get_schedule_history_entry),
)
.route(
"/{id}/schedule/history/{gen_id}/rollback",
post(schedule::rollback_schedule),
)
.route("/{id}/now", get(broadcast::get_current_broadcast))
.route("/{id}/epg", get(broadcast::get_epg))
.route("/{id}/stream", get(broadcast::get_stream))

View File

@@ -10,7 +10,7 @@ use uuid::Uuid;
use domain::{self, DomainError};
use crate::{
dto::ScheduleResponse,
dto::{ScheduleHistoryEntry, ScheduleResponse},
error::ApiError,
extractors::CurrentUser,
state::AppState,
@@ -60,3 +60,75 @@ pub(super) async fn get_active_schedule(
Ok(Json(ScheduleResponse::from(schedule)))
}
/// List all schedule generations for a channel, newest first.
/// Returns lightweight entries (no slots).
pub(super) async fn list_schedule_history(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
let history = state.schedule_engine.list_schedule_history(channel_id).await?;
let entries: Vec<ScheduleHistoryEntry> = history.into_iter().map(Into::into).collect();
Ok(Json(entries))
}
/// Fetch a single historical schedule with all its slots.
pub(super) async fn get_schedule_history_entry(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path((channel_id, gen_id)): Path<(Uuid, Uuid)>,
) -> Result<impl IntoResponse, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
let schedule = state
.schedule_engine
.get_schedule_by_id(channel_id, gen_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Schedule {} not found", gen_id)))?;
Ok(Json(ScheduleResponse::from(schedule)))
}
/// Roll back to a previous schedule generation.
///
/// Deletes all generations after `gen_id`'s generation, then generates a fresh
/// schedule from now (inheriting the rolled-back generation as the base for
/// recycle-policy history).
pub(super) async fn rollback_schedule(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path((channel_id, gen_id)): Path<(Uuid, Uuid)>,
) -> Result<impl IntoResponse, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
let target = state
.schedule_engine
.get_schedule_by_id(channel_id, gen_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Schedule {} not found", gen_id)))?;
state
.schedule_engine
.delete_schedules_after(channel_id, target.generation)
.await?;
let schedule = state
.schedule_engine
.generate_schedule(channel_id, Utc::now())
.await?;
let _ = state.event_tx.send(domain::DomainEvent::ScheduleGenerated {
channel_id,
schedule: schedule.clone(),
});
let detail = format!("rollback to gen {}; {} slots", target.generation, schedule.slots.len());
let _ = state.activity_log_repo.log("schedule_rollback", &detail, Some(channel_id)).await;
Ok((StatusCode::CREATED, Json(ScheduleResponse::from(schedule))))
}

View File

@@ -225,6 +225,32 @@ impl ScheduleEngineService {
self.provider_registry.get_stream_url(item_id, quality).await
}
/// List all generated schedule headers for a channel, newest first.
pub async fn list_schedule_history(
&self,
channel_id: ChannelId,
) -> DomainResult<Vec<GeneratedSchedule>> {
self.schedule_repo.list_schedule_history(channel_id).await
}
/// Fetch a specific schedule with its slots.
pub async fn get_schedule_by_id(
&self,
channel_id: ChannelId,
schedule_id: uuid::Uuid,
) -> DomainResult<Option<GeneratedSchedule>> {
self.schedule_repo.get_schedule_by_id(channel_id, schedule_id).await
}
/// Delete all schedules with generation > target_generation for this channel.
pub async fn delete_schedules_after(
&self,
channel_id: ChannelId,
target_generation: u32,
) -> DomainResult<()> {
self.schedule_repo.delete_schedules_after(channel_id, target_generation).await
}
/// Return all slots that overlap the given time window — the EPG data.
pub fn get_epg(
schedule: &GeneratedSchedule,