feat: wrapup date validation, delete endpoint, failed record cleanup
Some checks failed
CI / Check / Test (push) Failing after 41s

This commit is contained in:
2026-06-03 00:54:08 +02:00
parent 3f483f8f81
commit 241063c914
13 changed files with 194 additions and 7 deletions

View File

@@ -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<u64, DomainError> {
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<WrapUpRecord, DomainError> {

View File

@@ -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<u64, DomainError> {
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<WrapUpRecord, DomainError> {

View File

@@ -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(())
}
}

View File

@@ -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
}

View File

@@ -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<WrapUpId, DomainError> {
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<Wrap
status: WrapUpStatus::Pending,
report_json: None,
error_message: None,
created_at: chrono::Utc::now().naive_utc(),
created_at: Utc::now().naive_utc(),
completed_at: None,
};
ctx.repos.wrapup_repo.create(&record).await?;

View File

@@ -1,5 +1,6 @@
pub mod commands;
pub mod compute;
pub mod delete;
pub mod event_handler;
pub mod generate;
pub mod get_wrapup;

View File

@@ -495,6 +495,11 @@ pub trait WrapUpRepository: Send + Sync {
start: NaiveDate,
end: NaiveDate,
) -> Result<Option<WrapUpRecord>, DomainError>;
async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError>;
async fn delete_failed_older_than(
&self,
before: chrono::NaiveDateTime,
) -> Result<u64, DomainError>;
}
// ── Wrap-up / Year-in-Review ─────────────────────────────────────────────────

View File

@@ -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<u64, DomainError> {
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<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn delete(&self, _: &WrapUpId) -> Result<(), DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn delete_failed_older_than(
&self,
_: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
panic!("PanicWrapUpRepository called")
}
}

View File

@@ -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<AppState>, Path(id): Path<Uuid>) -> 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<AppState>,
_admin: AdminApiUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
delete_wrapup::execute(&state.app_ctx, WrapUpId::from_uuid(id)).await?;
Ok(StatusCode::NO_CONTENT)
}
// ── HTML handlers ───────────────────────────────────────────────────────────
fn format_watch_time(minutes: u32) -> String {

View File

@@ -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,

View File

@@ -360,7 +360,11 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
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),

View File

@@ -629,6 +629,15 @@ impl domain::ports::WrapUpRepository for Panic {
) -> Result<Option<domain::models::wrapup::WrapUpRecord>, DomainError> {
panic!()
}
async fn delete(&self, _: &domain::value_objects::WrapUpId) -> Result<(), DomainError> {
panic!()
}
async fn delete_failed_older_than(
&self,
_: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
panic!()
}
}
// --- Single state factory — only auth_service varies ---

View File

@@ -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);