feat: Add endpoint to delete recordings

This commit introduces a new API endpoint for deleting recordings. The
endpoint
handles the removal of the audio file and its associated sidecar files
(transcript,
duration). It also invalidates derived artifacts like oneliner.json,
document.md,
and analysis_input.json. Additionally, it includes checks for path
traversal,
correct file extensions, and case ownership to ensure security and data
integrity.
A new `RecordingDeleted` event is also added to the `CaseEventKind`
enum.
This commit is contained in:
2026-04-20 15:48:34 +02:00
parent 424330aad4
commit 5effa7c96e
11 changed files with 567 additions and 34 deletions
+52 -19
View File
@@ -110,31 +110,43 @@ pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<(PathBuf, St
let Some(slug) = user_entry.file_name().to_str().map(str::to_owned) else {
continue;
};
let Ok(mut cases) = tokio::fs::read_dir(&user_path).await else {
continue;
};
while let Ok(Some(case_entry)) = cases.next_entry().await {
let case_dir = case_entry.path();
if !case_dir.is_dir() || paths::is_deleted(&case_dir).await {
continue;
}
let (state, _) = paths::read_oneliner_state(&case_dir).await;
let needs_retry = match state {
None | Some(OnelinerState::Error { .. }) => true,
Some(OnelinerState::Ready { .. } | OnelinerState::Empty { .. }) => false,
};
if !needs_retry {
continue;
}
if has_non_empty_transcript(&case_dir).await {
out.push((case_dir, slug.clone()));
}
for case_dir in cases_needing_oneliner_in(&user_path).await {
out.push((case_dir, slug.clone()));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
/// Per-user variant of [`cases_needing_oneliner`]: scans only `user_root`'s
/// case dirs. Used both as the building block for the all-users scan and
/// from the page-load self-heal path.
pub(crate) async fn cases_needing_oneliner_in(user_root: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let Ok(mut cases) = tokio::fs::read_dir(user_root).await else {
return out;
};
while let Ok(Some(case_entry)) = cases.next_entry().await {
let case_dir = case_entry.path();
if !case_dir.is_dir() || paths::is_deleted(&case_dir).await {
continue;
}
let (state, _) = paths::read_oneliner_state(&case_dir).await;
let needs_retry = match state {
None | Some(OnelinerState::Error { .. }) => true,
Some(OnelinerState::Ready { .. } | OnelinerState::Empty { .. }) => false,
};
if !needs_retry {
continue;
}
if has_non_empty_transcript(&case_dir).await {
out.push(case_dir);
}
}
out.sort();
out
}
async fn has_non_empty_transcript(case_dir: &Path) -> bool {
let Ok(mut files) = tokio::fs::read_dir(case_dir).await else {
return false;
@@ -178,6 +190,27 @@ pub async fn regenerate_missing_oneliners(
}
}
/// Per-user variant of [`regenerate_missing_oneliners`]: fixes only the
/// given user's cases. Intended for the page-load self-heal path, so a
/// single missing oneliner is restored as soon as its owner opens a page
/// — no server restart needed.
pub async fn regenerate_missing_oneliners_for_user(
user_root: &Path,
slug: &str,
client: &reqwest::Client,
config: &Config,
vocab: &Gazetteer,
events_tx: &EventSender,
) {
let cases = cases_needing_oneliner_in(user_root).await;
if cases.is_empty() {
return;
}
for case_dir in cases {
super::worker::update_oneliner(&case_dir, slug, client, config, vocab, events_tx).await;
}
}
#[cfg(test)]
mod tests {
use super::*;