feat: wrapup date validation, delete endpoint, failed record cleanup
Some checks failed
CI / Check / Test (push) Failing after 41s
Some checks failed
CI / Check / Test (push) Failing after 41s
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
19
crates/application/src/wrapup/delete.rs
Normal file
19
crates/application/src/wrapup/delete.rs
Normal 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
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod commands;
|
||||
pub mod compute;
|
||||
pub mod delete;
|
||||
pub mod event_handler;
|
||||
pub mod generate;
|
||||
pub mod get_wrapup;
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user