From 241063c914868c98ba1104576a81d571bedf5ef7 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 3 Jun 2026 00:54:08 +0200 Subject: [PATCH] feat: wrapup date validation, delete endpoint, failed record cleanup --- crates/adapters/postgres/src/wrapup.rs | 23 +++++++++++++++ crates/adapters/sqlite/src/wrapup.rs | 24 ++++++++++++++++ crates/application/src/jobs.rs | 31 ++++++++++++++++++++ crates/application/src/wrapup/delete.rs | 19 ++++++++++++ crates/application/src/wrapup/generate.rs | 32 +++++++++++++++++---- crates/application/src/wrapup/mod.rs | 1 + crates/domain/src/ports.rs | 5 ++++ crates/domain/src/testing.rs | 27 +++++++++++++++++ crates/presentation/src/handlers/wrapup.rs | 22 +++++++++++++- crates/presentation/src/openapi/wrapup.rs | 1 + crates/presentation/src/routes.rs | 6 +++- crates/presentation/src/tests/extractors.rs | 9 ++++++ crates/worker/src/main.rs | 1 + 13 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 crates/application/src/wrapup/delete.rs diff --git a/crates/adapters/postgres/src/wrapup.rs b/crates/adapters/postgres/src/wrapup.rs index c721648..445ac15 100644 --- a/crates/adapters/postgres/src/wrapup.rs +++ b/crates/adapters/postgres/src/wrapup.rs @@ -190,6 +190,29 @@ impl WrapUpRepository for PostgresWrapUpRepository { row.as_ref().map(row_to_record).transpose() } + + async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { + sqlx::query("DELETE FROM wrap_up_records WHERE id = $1") + .bind(id.value().to_string()) + .execute(&self.pool) + .await + .map_err(map_err)?; + Ok(()) + } + + async fn delete_failed_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result { + let result = sqlx::query( + "DELETE FROM wrap_up_records WHERE status = 'failed' AND created_at < $1", + ) + .bind(before) + .execute(&self.pool) + .await + .map_err(map_err)?; + Ok(result.rows_affected()) + } } fn row_to_record(row: &sqlx::postgres::PgRow) -> Result { diff --git a/crates/adapters/sqlite/src/wrapup.rs b/crates/adapters/sqlite/src/wrapup.rs index f07a1bf..3f466e4 100644 --- a/crates/adapters/sqlite/src/wrapup.rs +++ b/crates/adapters/sqlite/src/wrapup.rs @@ -200,6 +200,30 @@ impl WrapUpRepository for SqliteWrapUpRepository { row.as_ref().map(row_to_record).transpose() } + + async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { + sqlx::query("DELETE FROM wrap_up_records WHERE id = ?") + .bind(id.value().to_string()) + .execute(&self.pool) + .await + .map_err(map_err)?; + Ok(()) + } + + async fn delete_failed_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result { + let before_str = before.format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let result = sqlx::query( + "DELETE FROM wrap_up_records WHERE status = 'failed' AND created_at < ?", + ) + .bind(&before_str) + .execute(&self.pool) + .await + .map_err(map_err)?; + Ok(result.rows_affected()) + } } fn row_to_record(row: &sqlx::sqlite::SqliteRow) -> Result { diff --git a/crates/application/src/jobs.rs b/crates/application/src/jobs.rs index 87abbe9..78c06a3 100644 --- a/crates/application/src/jobs.rs +++ b/crates/application/src/jobs.rs @@ -161,3 +161,34 @@ impl PeriodicJob for WrapUpAutoGenerateJob { Ok(()) } } + +pub struct WrapUpCleanupJob { + ctx: AppContext, +} + +impl WrapUpCleanupJob { + pub fn new(ctx: AppContext) -> Self { + Self { ctx } + } +} + +#[async_trait] +impl PeriodicJob for WrapUpCleanupJob { + fn interval(&self) -> Duration { + Duration::from_secs(86400) + } + + async fn run(&self) -> Result<(), DomainError> { + let cutoff = chrono::Utc::now().naive_utc() - chrono::Duration::days(7); + let n = self + .ctx + .repos + .wrapup_repo + .delete_failed_older_than(cutoff) + .await?; + if n > 0 { + tracing::info!("wrapup cleanup: removed {n} failed records"); + } + Ok(()) + } +} diff --git a/crates/application/src/wrapup/delete.rs b/crates/application/src/wrapup/delete.rs new file mode 100644 index 0000000..013fd95 --- /dev/null +++ b/crates/application/src/wrapup/delete.rs @@ -0,0 +1,19 @@ +use domain::errors::DomainError; +use domain::value_objects::WrapUpId; + +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, id: WrapUpId) -> Result<(), DomainError> { + let record = ctx + .repos + .wrapup_repo + .get_by_id(&id) + .await? + .ok_or_else(|| DomainError::NotFound("wrap-up not found".into()))?; + + let wrapup_key = format!("wrapups/{}", id.value()); + let video_key = format!("{wrapup_key}/video.mp4"); + let _ = ctx.services.image_storage.delete(&video_key).await; + + ctx.repos.wrapup_repo.delete(&record.id).await +} diff --git a/crates/application/src/wrapup/generate.rs b/crates/application/src/wrapup/generate.rs index 6eded68..d89cb9e 100644 --- a/crates/application/src/wrapup/generate.rs +++ b/crates/application/src/wrapup/generate.rs @@ -1,3 +1,4 @@ +use chrono::Utc; use domain::errors::DomainError; use domain::events::DomainEvent; use domain::models::wrapup::WrapUpStatus; @@ -7,16 +8,37 @@ use crate::context::AppContext; use crate::wrapup::commands::RequestWrapUpCommand; pub async fn execute(ctx: &AppContext, cmd: RequestWrapUpCommand) -> Result { + if cmd.end_date <= cmd.start_date { + return Err(DomainError::ValidationError( + "end_date must be after start_date".into(), + )); + } + let days = (cmd.end_date - cmd.start_date).num_days(); + if days > 366 { + return Err(DomainError::ValidationError( + "date range cannot exceed 366 days".into(), + )); + } + if cmd.end_date > Utc::now().date_naive() { + return Err(DomainError::ValidationError( + "end_date cannot be in the future".into(), + )); + } + let existing = ctx .repos .wrapup_repo .find_existing(cmd.user_id, cmd.start_date, cmd.end_date) .await?; - if let Some(ref rec) = existing - && (rec.status == WrapUpStatus::Ready || rec.status == WrapUpStatus::Generating) - { - return Ok(rec.id.clone()); + if let Some(ref rec) = existing { + match rec.status { + WrapUpStatus::Ready | WrapUpStatus::Generating => return Ok(rec.id.clone()), + WrapUpStatus::Failed => { + ctx.repos.wrapup_repo.delete(&rec.id).await?; + } + WrapUpStatus::Pending => return Ok(rec.id.clone()), + } } let id = WrapUpId::generate(); @@ -28,7 +50,7 @@ pub async fn execute(ctx: &AppContext, cmd: RequestWrapUpCommand) -> Result Result, DomainError>; + async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError>; + async fn delete_failed_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result; } // ── Wrap-up / Year-in-Review ───────────────────────────────────────────────── diff --git a/crates/domain/src/testing.rs b/crates/domain/src/testing.rs index 2f67f84..cd876dc 100644 --- a/crates/domain/src/testing.rs +++ b/crates/domain/src/testing.rs @@ -1152,6 +1152,24 @@ impl WrapUpRepository for InMemoryWrapUpRepository { .find(|r| r.user_id == user_id && r.start_date == start && r.end_date == end) .cloned()) } + + async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { + let mut store = self.store.lock().unwrap(); + store.retain(|r| r.id != *id); + Ok(()) + } + + async fn delete_failed_older_than( + &self, + before: chrono::NaiveDateTime, + ) -> Result { + let mut store = self.store.lock().unwrap(); + let before_len = store.len(); + store.retain(|r| { + !(r.status == crate::models::wrapup::WrapUpStatus::Failed && r.created_at < before) + }); + Ok((before_len - store.len()) as u64) + } } // ── PanicWrapUpRepository ────────────────────────────────────────────────── @@ -1197,4 +1215,13 @@ impl WrapUpRepository for PanicWrapUpRepository { ) -> Result, DomainError> { panic!("PanicWrapUpRepository called") } + async fn delete(&self, _: &WrapUpId) -> Result<(), DomainError> { + panic!("PanicWrapUpRepository called") + } + async fn delete_failed_older_than( + &self, + _: chrono::NaiveDateTime, + ) -> Result { + panic!("PanicWrapUpRepository called") + } } diff --git a/crates/presentation/src/handlers/wrapup.rs b/crates/presentation/src/handlers/wrapup.rs index f087688..2c09045 100644 --- a/crates/presentation/src/handlers/wrapup.rs +++ b/crates/presentation/src/handlers/wrapup.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use application::wrapup::{ commands::RequestWrapUpCommand, - generate, get_wrapup, + delete as delete_wrapup, generate, get_wrapup, list_wrapups::{self, ListWrapUpsQuery}, }; use domain::errors::DomainError; @@ -194,6 +194,26 @@ pub async fn get_video(State(state): State, Path(id): Path) -> i } } +#[utoipa::path( + delete, path = "/api/v1/wrapups/{id}", + params(("id" = Uuid, Path, description = "Wrap-up ID")), + responses( + (status = 204, description = "Deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden — admin only"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_wrapup_handler( + State(state): State, + _admin: AdminApiUser, + Path(id): Path, +) -> Result { + delete_wrapup::execute(&state.app_ctx, WrapUpId::from_uuid(id)).await?; + Ok(StatusCode::NO_CONTENT) +} + // ── HTML handlers ─────────────────────────────────────────────────────────── fn format_watch_time(minutes: u32) -> String { diff --git a/crates/presentation/src/openapi/wrapup.rs b/crates/presentation/src/openapi/wrapup.rs index e79b644..d6dded4 100644 --- a/crates/presentation/src/openapi/wrapup.rs +++ b/crates/presentation/src/openapi/wrapup.rs @@ -8,6 +8,7 @@ use utoipa::OpenApi; crate::handlers::wrapup::get_status, crate::handlers::wrapup::get_report, crate::handlers::wrapup::get_video, + crate::handlers::wrapup::delete_wrapup_handler, ), components(schemas( api_types::wrapup::GenerateWrapUpRequest, diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index b85286d..f5a80d0 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -360,7 +360,11 @@ fn api_routes(rate_limit: u64) -> Router { routing::post(handlers::wrapup::post_generate), ) .route("/wrapups", routing::get(handlers::wrapup::get_list)) - .route("/wrapups/{id}", routing::get(handlers::wrapup::get_status)) + .route( + "/wrapups/{id}", + routing::get(handlers::wrapup::get_status) + .delete(handlers::wrapup::delete_wrapup_handler), + ) .route( "/wrapups/{id}/report", routing::get(handlers::wrapup::get_report), diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 01d1d58..3eeb992 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -629,6 +629,15 @@ impl domain::ports::WrapUpRepository for Panic { ) -> Result, DomainError> { panic!() } + async fn delete(&self, _: &domain::value_objects::WrapUpId) -> Result<(), DomainError> { + panic!() + } + async fn delete_failed_older_than( + &self, + _: chrono::NaiveDateTime, + ) -> Result { + panic!() + } } // --- Single state factory — only auth_service varies --- diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 93bf05c..c02f5e8 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -160,6 +160,7 @@ async fn main() -> anyhow::Result<()> { Arc::new(application::jobs::ImportSessionCleanupJob::new(ctx.clone())), Arc::new(application::jobs::WatchEventCleanupJob::new(ctx.clone())), Arc::new(application::jobs::WrapUpAutoGenerateJob::new(ctx.clone())), + Arc::new(application::jobs::WrapUpCleanupJob::new(ctx.clone())), ]; if let Some(job) = enrichment_job { periodic_jobs.push(job);