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()
|
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> {
|
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()
|
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> {
|
fn row_to_record(row: &sqlx::sqlite::SqliteRow) -> Result<WrapUpRecord, DomainError> {
|
||||||
|
|||||||
@@ -161,3 +161,34 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
|
|||||||
Ok(())
|
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::errors::DomainError;
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::models::wrapup::WrapUpStatus;
|
use domain::models::wrapup::WrapUpStatus;
|
||||||
@@ -7,16 +8,37 @@ use crate::context::AppContext;
|
|||||||
use crate::wrapup::commands::RequestWrapUpCommand;
|
use crate::wrapup::commands::RequestWrapUpCommand;
|
||||||
|
|
||||||
pub async fn execute(ctx: &AppContext, cmd: RequestWrapUpCommand) -> Result<WrapUpId, DomainError> {
|
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
|
let existing = ctx
|
||||||
.repos
|
.repos
|
||||||
.wrapup_repo
|
.wrapup_repo
|
||||||
.find_existing(cmd.user_id, cmd.start_date, cmd.end_date)
|
.find_existing(cmd.user_id, cmd.start_date, cmd.end_date)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(ref rec) = existing
|
if let Some(ref rec) = existing {
|
||||||
&& (rec.status == WrapUpStatus::Ready || rec.status == WrapUpStatus::Generating)
|
match rec.status {
|
||||||
{
|
WrapUpStatus::Ready | WrapUpStatus::Generating => return Ok(rec.id.clone()),
|
||||||
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();
|
let id = WrapUpId::generate();
|
||||||
@@ -28,7 +50,7 @@ pub async fn execute(ctx: &AppContext, cmd: RequestWrapUpCommand) -> Result<Wrap
|
|||||||
status: WrapUpStatus::Pending,
|
status: WrapUpStatus::Pending,
|
||||||
report_json: None,
|
report_json: None,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
created_at: chrono::Utc::now().naive_utc(),
|
created_at: Utc::now().naive_utc(),
|
||||||
completed_at: None,
|
completed_at: None,
|
||||||
};
|
};
|
||||||
ctx.repos.wrapup_repo.create(&record).await?;
|
ctx.repos.wrapup_repo.create(&record).await?;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod compute;
|
pub mod compute;
|
||||||
|
pub mod delete;
|
||||||
pub mod event_handler;
|
pub mod event_handler;
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
pub mod get_wrapup;
|
pub mod get_wrapup;
|
||||||
|
|||||||
@@ -495,6 +495,11 @@ pub trait WrapUpRepository: Send + Sync {
|
|||||||
start: NaiveDate,
|
start: NaiveDate,
|
||||||
end: NaiveDate,
|
end: NaiveDate,
|
||||||
) -> Result<Option<WrapUpRecord>, DomainError>;
|
) -> 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 ─────────────────────────────────────────────────
|
// ── 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)
|
.find(|r| r.user_id == user_id && r.start_date == start && r.end_date == end)
|
||||||
.cloned())
|
.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 ──────────────────────────────────────────────────
|
// ── PanicWrapUpRepository ──────────────────────────────────────────────────
|
||||||
@@ -1197,4 +1215,13 @@ impl WrapUpRepository for PanicWrapUpRepository {
|
|||||||
) -> Result<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
|
) -> Result<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
|
||||||
panic!("PanicWrapUpRepository called")
|
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::{
|
use application::wrapup::{
|
||||||
commands::RequestWrapUpCommand,
|
commands::RequestWrapUpCommand,
|
||||||
generate, get_wrapup,
|
delete as delete_wrapup, generate, get_wrapup,
|
||||||
list_wrapups::{self, ListWrapUpsQuery},
|
list_wrapups::{self, ListWrapUpsQuery},
|
||||||
};
|
};
|
||||||
use domain::errors::DomainError;
|
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 ───────────────────────────────────────────────────────────
|
// ── HTML handlers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn format_watch_time(minutes: u32) -> String {
|
fn format_watch_time(minutes: u32) -> String {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use utoipa::OpenApi;
|
|||||||
crate::handlers::wrapup::get_status,
|
crate::handlers::wrapup::get_status,
|
||||||
crate::handlers::wrapup::get_report,
|
crate::handlers::wrapup::get_report,
|
||||||
crate::handlers::wrapup::get_video,
|
crate::handlers::wrapup::get_video,
|
||||||
|
crate::handlers::wrapup::delete_wrapup_handler,
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
api_types::wrapup::GenerateWrapUpRequest,
|
api_types::wrapup::GenerateWrapUpRequest,
|
||||||
|
|||||||
@@ -360,7 +360,11 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
|||||||
routing::post(handlers::wrapup::post_generate),
|
routing::post(handlers::wrapup::post_generate),
|
||||||
)
|
)
|
||||||
.route("/wrapups", routing::get(handlers::wrapup::get_list))
|
.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(
|
.route(
|
||||||
"/wrapups/{id}/report",
|
"/wrapups/{id}/report",
|
||||||
routing::get(handlers::wrapup::get_report),
|
routing::get(handlers::wrapup::get_report),
|
||||||
|
|||||||
@@ -629,6 +629,15 @@ impl domain::ports::WrapUpRepository for Panic {
|
|||||||
) -> Result<Option<domain::models::wrapup::WrapUpRecord>, DomainError> {
|
) -> Result<Option<domain::models::wrapup::WrapUpRecord>, DomainError> {
|
||||||
panic!()
|
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 ---
|
// --- 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::ImportSessionCleanupJob::new(ctx.clone())),
|
||||||
Arc::new(application::jobs::WatchEventCleanupJob::new(ctx.clone())),
|
Arc::new(application::jobs::WatchEventCleanupJob::new(ctx.clone())),
|
||||||
Arc::new(application::jobs::WrapUpAutoGenerateJob::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 {
|
if let Some(job) = enrichment_job {
|
||||||
periodic_jobs.push(job);
|
periodic_jobs.push(job);
|
||||||
|
|||||||
Reference in New Issue
Block a user