feat: config history — auto-snapshot on update, list/pin/restore endpoints
This commit is contained in:
72
k-tv-backend/api/src/routes/channels/config_history.rs
Normal file
72
k-tv-backend/api/src/routes/channels/config_history.rs
Normal file
@@ -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<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 snapshots = state.channel_service.list_config_snapshots(channel_id).await?;
|
||||||
|
let response: Vec<ConfigSnapshotResponse> = snapshots.into_iter().map(Into::into).collect();
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn patch_config_snapshot(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path((channel_id, snap_id)): Path<(Uuid, Uuid)>,
|
||||||
|
Json(payload): Json<PatchSnapshotRequest>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
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<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path((channel_id, snap_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 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))))
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use domain::{AccessMode, User};
|
|||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
mod broadcast;
|
mod broadcast;
|
||||||
|
mod config_history;
|
||||||
mod crud;
|
mod crud;
|
||||||
mod schedule;
|
mod schedule;
|
||||||
|
|
||||||
@@ -39,6 +40,18 @@ pub fn router() -> Router<AppState> {
|
|||||||
.route("/{id}/now", get(broadcast::get_current_broadcast))
|
.route("/{id}/now", get(broadcast::get_current_broadcast))
|
||||||
.route("/{id}/epg", get(broadcast::get_epg))
|
.route("/{id}/epg", get(broadcast::get_epg))
|
||||||
.route("/{id}/stream", get(broadcast::get_stream))
|
.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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::entities::Channel;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::entities::{Channel, ChannelConfigSnapshot, ScheduleConfig};
|
||||||
use crate::errors::{DomainError, DomainResult};
|
use crate::errors::{DomainError, DomainResult};
|
||||||
use crate::repositories::ChannelRepository;
|
use crate::repositories::ChannelRepository;
|
||||||
use crate::value_objects::{ChannelId, UserId};
|
use crate::value_objects::{ChannelId, UserId};
|
||||||
@@ -42,10 +44,73 @@ impl ChannelService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update(&self, channel: Channel) -> DomainResult<Channel> {
|
pub async fn update(&self, channel: Channel) -> DomainResult<Channel> {
|
||||||
|
// 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?;
|
self.channel_repo.save(&channel).await?;
|
||||||
Ok(channel)
|
Ok(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_config_snapshots(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
) -> DomainResult<Vec<ChannelConfigSnapshot>> {
|
||||||
|
self.channel_repo.list_config_snapshots(channel_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_config_snapshot(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
snapshot_id: Uuid,
|
||||||
|
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
||||||
|
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<String>,
|
||||||
|
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
||||||
|
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<Channel> {
|
||||||
|
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<String>,
|
||||||
|
) -> DomainResult<ChannelConfigSnapshot> {
|
||||||
|
self.channel_repo.save_config_snapshot(channel_id, config, label).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete a channel, enforcing that `requester_id` is the owner.
|
/// Delete a channel, enforcing that `requester_id` is the owner.
|
||||||
pub async fn delete(&self, id: ChannelId, requester_id: UserId) -> DomainResult<()> {
|
pub async fn delete(&self, id: ChannelId, requester_id: UserId) -> DomainResult<()> {
|
||||||
let channel = self.find_by_id(id).await?;
|
let channel = self.find_by_id(id).await?;
|
||||||
|
|||||||
Reference in New Issue
Block a user