diff --git a/k-tv-backend/api/src/dto.rs b/k-tv-backend/api/src/dto.rs index 14c5462..e182b0b 100644 --- a/k-tv-backend/api/src/dto.rs +++ b/k-tv-backend/api/src/dto.rs @@ -180,6 +180,34 @@ impl From for ChannelResponse { } } +// ============================================================================ +// Config history DTOs +// ============================================================================ + +#[derive(Debug, Serialize)] +pub struct ConfigSnapshotResponse { + pub id: Uuid, + pub version_num: i64, + pub label: Option, + pub created_at: DateTime, +} + +impl From 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, +} + // ============================================================================ // 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, + pub valid_until: DateTime, + pub slot_count: usize, +} + +impl From 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 for ScheduleResponse { fn from(s: domain::GeneratedSchedule) -> Self { Self { diff --git a/k-tv-backend/api/src/routes/channels/mod.rs b/k-tv-backend/api/src/routes/channels/mod.rs index 14aee1c..ee57b97 100644 --- a/k-tv-backend/api/src/routes/channels/mod.rs +++ b/k-tv-backend/api/src/routes/channels/mod.rs @@ -27,6 +27,15 @@ pub fn router() -> Router { "/{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)) diff --git a/k-tv-backend/api/src/routes/channels/schedule.rs b/k-tv-backend/api/src/routes/channels/schedule.rs index f465403..ffd6a21 100644 --- a/k-tv-backend/api/src/routes/channels/schedule.rs +++ b/k-tv-backend/api/src/routes/channels/schedule.rs @@ -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, + CurrentUser(user): CurrentUser, + Path(channel_id): Path, +) -> Result { + 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 = 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, + CurrentUser(user): CurrentUser, + Path((channel_id, gen_id)): Path<(Uuid, Uuid)>, +) -> Result { + 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, + CurrentUser(user): CurrentUser, + Path((channel_id, gen_id)): Path<(Uuid, Uuid)>, +) -> Result { + 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)))) +} diff --git a/k-tv-backend/domain/src/services/schedule/mod.rs b/k-tv-backend/domain/src/services/schedule/mod.rs index 65e0857..207a722 100644 --- a/k-tv-backend/domain/src/services/schedule/mod.rs +++ b/k-tv-backend/domain/src/services/schedule/mod.rs @@ -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> { + 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> { + 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,