From 20e80ac28e50bbc34a2ad161beb29c7c2b8bc589 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 17 Mar 2026 14:39:09 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20config=20history=20=E2=80=94=20auto-sna?= =?UTF-8?q?pshot=20on=20update,=20list/pin/restore=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/src/routes/channels/config_history.rs | 72 +++++++++++++++++++ k-tv-backend/api/src/routes/channels/mod.rs | 13 ++++ k-tv-backend/domain/src/services/channel.rs | 67 ++++++++++++++++- 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 k-tv-backend/api/src/routes/channels/config_history.rs diff --git a/k-tv-backend/api/src/routes/channels/config_history.rs b/k-tv-backend/api/src/routes/channels/config_history.rs new file mode 100644 index 0000000..1d6e5ec --- /dev/null +++ b/k-tv-backend/api/src/routes/channels/config_history.rs @@ -0,0 +1,72 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use uuid::Uuid; + +use crate::{ + dto::{ChannelResponse, ConfigSnapshotResponse, PatchSnapshotRequest}, + error::ApiError, + extractors::CurrentUser, + state::AppState, +}; + +use super::require_owner; + +pub(super) async fn list_config_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 snapshots = state.channel_service.list_config_snapshots(channel_id).await?; + let response: Vec = snapshots.into_iter().map(Into::into).collect(); + Ok(Json(response)) +} + +pub(super) async fn patch_config_snapshot( + State(state): State, + CurrentUser(user): CurrentUser, + Path((channel_id, snap_id)): Path<(Uuid, Uuid)>, + Json(payload): Json, +) -> Result { + let channel = state.channel_service.find_by_id(channel_id).await?; + require_owner(&channel, user.id)?; + + let updated = state + .channel_service + .patch_config_snapshot_label(channel_id, snap_id, payload.label) + .await? + .ok_or_else(|| ApiError::NotFound("Snapshot not found".into()))?; + + Ok(Json(ConfigSnapshotResponse::from(updated))) +} + +pub(super) async fn restore_config_snapshot( + State(state): State, + CurrentUser(user): CurrentUser, + Path((channel_id, snap_id)): Path<(Uuid, Uuid)>, +) -> Result { + let channel = state.channel_service.find_by_id(channel_id).await?; + require_owner(&channel, user.id)?; + + let updated = state + .channel_service + .restore_config_snapshot(channel_id, snap_id) + .await + .map_err(|e| match e { + domain::DomainError::ChannelNotFound(_) => ApiError::NotFound("Snapshot not found".into()), + other => ApiError::from(other), + })?; + + let _ = state + .activity_log_repo + .log("config_restored", &snap_id.to_string(), Some(channel_id)) + .await; + + Ok((StatusCode::OK, Json(ChannelResponse::from(updated)))) +} diff --git a/k-tv-backend/api/src/routes/channels/mod.rs b/k-tv-backend/api/src/routes/channels/mod.rs index ee57b97..dee7cbf 100644 --- a/k-tv-backend/api/src/routes/channels/mod.rs +++ b/k-tv-backend/api/src/routes/channels/mod.rs @@ -13,6 +13,7 @@ use domain::{AccessMode, User}; use crate::{error::ApiError, state::AppState}; mod broadcast; +mod config_history; mod crud; mod schedule; @@ -39,6 +40,18 @@ pub fn router() -> Router { .route("/{id}/now", get(broadcast::get_current_broadcast)) .route("/{id}/epg", get(broadcast::get_epg)) .route("/{id}/stream", get(broadcast::get_stream)) + .route( + "/{id}/config/history", + get(config_history::list_config_history), + ) + .route( + "/{id}/config/history/{snap_id}", + axum::routing::patch(config_history::patch_config_snapshot), + ) + .route( + "/{id}/config/history/{snap_id}/restore", + post(config_history::restore_config_snapshot), + ) } // ============================================================================ diff --git a/k-tv-backend/domain/src/services/channel.rs b/k-tv-backend/domain/src/services/channel.rs index 04a56dd..1756d70 100644 --- a/k-tv-backend/domain/src/services/channel.rs +++ b/k-tv-backend/domain/src/services/channel.rs @@ -1,6 +1,8 @@ use std::sync::Arc; -use crate::entities::Channel; +use uuid::Uuid; + +use crate::entities::{Channel, ChannelConfigSnapshot, ScheduleConfig}; use crate::errors::{DomainError, DomainResult}; use crate::repositories::ChannelRepository; use crate::value_objects::{ChannelId, UserId}; @@ -42,10 +44,73 @@ impl ChannelService { } pub async fn update(&self, channel: Channel) -> DomainResult { + // Auto-snapshot the config being replaced + self.channel_repo + .save_config_snapshot(channel.id, &channel.schedule_config, None) + .await?; self.channel_repo.save(&channel).await?; Ok(channel) } + pub async fn list_config_snapshots( + &self, + channel_id: ChannelId, + ) -> DomainResult> { + self.channel_repo.list_config_snapshots(channel_id).await + } + + pub async fn get_config_snapshot( + &self, + channel_id: ChannelId, + snapshot_id: Uuid, + ) -> DomainResult> { + self.channel_repo.get_config_snapshot(channel_id, snapshot_id).await + } + + pub async fn patch_config_snapshot_label( + &self, + channel_id: ChannelId, + snapshot_id: Uuid, + label: Option, + ) -> DomainResult> { + self.channel_repo.patch_config_snapshot_label(channel_id, snapshot_id, label).await + } + + /// Restore a snapshot: auto-snapshot current config, then apply the snapshot's config. + pub async fn restore_config_snapshot( + &self, + channel_id: ChannelId, + snapshot_id: Uuid, + ) -> DomainResult { + let snapshot = self + .channel_repo + .get_config_snapshot(channel_id, snapshot_id) + .await? + .ok_or(DomainError::ChannelNotFound(channel_id))?; + let mut channel = self + .channel_repo + .find_by_id(channel_id) + .await? + .ok_or(DomainError::ChannelNotFound(channel_id))?; + // Snapshot current config before overwriting + self.channel_repo + .save_config_snapshot(channel_id, &channel.schedule_config, None) + .await?; + channel.schedule_config = snapshot.config; + channel.updated_at = chrono::Utc::now(); + self.channel_repo.save(&channel).await?; + Ok(channel) + } + + pub async fn save_config_snapshot( + &self, + channel_id: ChannelId, + config: &ScheduleConfig, + label: Option, + ) -> DomainResult { + self.channel_repo.save_config_snapshot(channel_id, config, label).await + } + /// Delete a channel, enforcing that `requester_id` is the owner. pub async fn delete(&self, id: ChannelId, requester_id: UserId) -> DomainResult<()> { let channel = self.find_by_id(id).await?;