diff --git a/core/redis.py b/core/redis.py
index 459b031..b373a43 100644
--- a/core/redis.py
+++ b/core/redis.py
@@ -132,3 +132,21 @@ async def get_job_status_async(job_id: str) -> dict | None:
except Exception as e:
logger.warning("Redis get_job_status_async failed for %s: %s", job_id, e)
return None
+
+
+async def delete_job_status_async(job_id: str) -> bool:
+ """Remove a job's status key from Redis (user deleted it from history).
+
+ Best-effort like the other job-status helpers: never raises, returns
+ True when the key was deleted, False when Redis is unavailable or the
+ key did not exist.
+ """
+ client = get_async_redis()
+ if not client:
+ return False
+ try:
+ key = f"{JOB_STATUS_KEY_PREFIX}:{job_id}"
+ return bool(await client.delete(key))
+ except Exception as e:
+ logger.warning("Redis delete_job_status_async failed for %s: %s", job_id, e)
+ return False
diff --git a/frontend/src/app/dashboard/reviews/[jobId]/page.tsx b/frontend/src/app/dashboard/reviews/[jobId]/page.tsx
index 11548e8..bd78754 100644
--- a/frontend/src/app/dashboard/reviews/[jobId]/page.tsx
+++ b/frontend/src/app/dashboard/reviews/[jobId]/page.tsx
@@ -106,7 +106,13 @@ export default function ReviewPage() {
setSegments(res.data.segments);
setFileName(res.data.file_name);
} catch (err) {
- setLoadError(err instanceof Error ? err.message : t('reviews.error.load'));
+ setLoadError(
+ friendlyReviewError(
+ err instanceof Error ? err.message : undefined,
+ t,
+ 'reviews.error.load'
+ ) ?? t('reviews.error.load')
+ );
} finally {
setIsLoading(false);
}
@@ -406,6 +412,15 @@ export default function ReviewPage() {
+ ) : segments.length === 0 && !loadError ? (
+
+
+ {t('reviews.empty.title')}
+
+
+ {t('reviews.empty.desc')}
+
+
) : (
diff --git a/frontend/src/app/dashboard/translate/page.tsx b/frontend/src/app/dashboard/translate/page.tsx
index cfed5bb..a0cc1e8 100644
--- a/frontend/src/app/dashboard/translate/page.tsx
+++ b/frontend/src/app/dashboard/translate/page.tsx
@@ -8,11 +8,14 @@ import {
Zap, CheckCircle2,
Search, Languages, Wrench, Activity,
Download, AlertTriangle, FileType,
- Image as ImageIcon,
+ Image as ImageIcon, Trash2,
} from 'lucide-react';
import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig';
-import { useTranslationSubmit, getRecentJobs, fetchServerHistory, pushRecentJob, type RecentJob } from './useTranslationSubmit';
+import {
+ useTranslationSubmit, getRecentJobs, fetchServerHistory, pushRecentJob,
+ removeRecentJob, deleteServerJob, type RecentJob,
+} from './useTranslationSubmit';
import { runTranslationJob } from './translationRunner';
import type { BatchItem } from './types';
import LanguageSelector from './LanguageSelector';
@@ -83,6 +86,8 @@ export default function TranslatePage() {
const batchJobRef = useRef(null);
const [elapsed, setElapsed] = useState(0);
const [recentJobs, setRecentJobs] = useState([]);
+ // Job whose delete button is armed (first click) — cleared after 4s or on delete.
+ const [armedRemoveId, setArmedRemoveId] = useState(null);
const timerRef = useRef | null>(null);
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
@@ -238,6 +243,20 @@ export default function TranslatePage() {
};
const handleNewTranslation = () => { batchAbortRef.current = true; setBatch(null); submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
+
+ /** Two-step delete (arm, then confirm): removes the entry locally and on the server. */
+ const handleRemoveJob = (jobId: string) => {
+ if (armedRemoveId !== jobId) {
+ setArmedRemoveId(jobId);
+ setTimeout(() => setArmedRemoveId((cur) => (cur === jobId ? null : cur)), 4000);
+ return;
+ }
+ setArmedRemoveId(null);
+ removeRecentJob(jobId);
+ setRecentJobs((jobs) => jobs.filter((j) => j.jobId !== jobId));
+ // Server-side cleanup is best-effort: a 404 just means it already forgot the job.
+ void deleteServerJob(jobId);
+ };
const handleDownload = async (jobId: string = submit.jobId ?? '') => {
if (!jobId) return;
const token = localStorage.getItem('token');
@@ -543,7 +562,7 @@ export default function TranslatePage() {
onClick={() => handleDownload(job.jobId)}
className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60 border border-black/10 dark:border-white/10 hover:bg-brand-muted/50 transition-colors"
>
-
+
{t('translate.recent.review')}
+
))}
diff --git a/frontend/src/app/dashboard/translate/useTranslationSubmit.ts b/frontend/src/app/dashboard/translate/useTranslationSubmit.ts
index aea47a6..23d14f6 100644
--- a/frontend/src/app/dashboard/translate/useTranslationSubmit.ts
+++ b/frontend/src/app/dashboard/translate/useTranslationSubmit.ts
@@ -22,7 +22,7 @@ interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
export interface RecentJob { jobId: string; fileName: string; completedAt: number }
-/** Server history (last jobs, newest first) — empty when offline/unauthenticated. */
+/** Server history (last completed jobs, newest first) — empty when offline/unauthenticated. */
export async function fetchServerHistory(perPage = 6): Promise {
if (typeof window === 'undefined') return [];
const token = localStorage.getItem('token');
@@ -33,14 +33,16 @@ export async function fetchServerHistory(perPage = 6): Promise {
});
if (!res.ok) return [];
const json = await res.json();
- return (json.data ?? []).map((j: {
- id: string; file_name?: string | null;
- completed_at?: string | null; created_at?: string | null;
- }) => ({
- jobId: j.id,
- fileName: j.file_name ?? '',
- completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(),
- }));
+ return (json.data ?? [])
+ .filter((j: { status?: string | null }) => (j.status ?? 'completed') === 'completed')
+ .map((j: {
+ id: string; file_name?: string | null; status?: string | null;
+ completed_at?: string | null; created_at?: string | null;
+ }) => ({
+ jobId: j.id,
+ fileName: j.file_name ?? '',
+ completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(),
+ }));
} catch {
return [];
}
@@ -56,6 +58,31 @@ export function getRecentJobs(): RecentJob[] {
}
}
+/** Remove a job from this browser's local history. */
+export function removeRecentJob(jobId: string) {
+ try {
+ const list = getRecentJobs().filter(j => j.jobId !== jobId);
+ localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(list));
+ } catch { /* ignore */ }
+}
+
+/**
+ * Delete a job on the server (history entry + review segments).
+ * Silent by design: a 404 means the server already forgot the job, which
+ * is exactly the state the caller wants.
+ */
+export async function deleteServerJob(jobId: string): Promise {
+ if (typeof window === 'undefined') return;
+ const token = localStorage.getItem('token');
+ if (!token) return;
+ try {
+ await fetch(`${API_BASE}/api/v1/translations/${jobId}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ } catch { /* offline — the local entry is removed regardless */ }
+}
+
function persistActiveJob(job: StoredJob | null) {
try {
if (!job) localStorage.removeItem(ACTIVE_JOB_KEY);
diff --git a/frontend/src/lib/i18n/messages/ar/reviews.json b/frontend/src/lib/i18n/messages/ar/reviews.json
index b780720..cb427fc 100644
--- a/frontend/src/lib/i18n/messages/ar/reviews.json
+++ b/frontend/src/lib/i18n/messages/ar/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "لا توجد أجزاء لمراجعتها في هذه المهمة",
+ "reviews.empty.desc": "لا تحتوي هذه الترجمة على أجزاء محفوظة: إما أنها فشلت قبل ترجمة النص، أو لم يعد الخادم يحتفظ بها. يمكنك إزالتها من قائمة الترجمات الأخيرة."
}
diff --git a/frontend/src/lib/i18n/messages/de/reviews.json b/frontend/src/lib/i18n/messages/de/reviews.json
index b780720..7bc519d 100644
--- a/frontend/src/lib/i18n/messages/de/reviews.json
+++ b/frontend/src/lib/i18n/messages/de/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "Für diesen Auftrag gibt es nichts zu prüfen",
+ "reviews.empty.desc": "Diese Übersetzung enthält keine gespeicherten Segmente: Entweder ist sie vor der Textübersetzung fehlgeschlagen, oder der Server bewahrt sie nicht mehr auf. Sie können sie aus der Liste der letzten Übersetzungen entfernen."
}
diff --git a/frontend/src/lib/i18n/messages/en/reviews.json b/frontend/src/lib/i18n/messages/en/reviews.json
index b780720..df23b68 100644
--- a/frontend/src/lib/i18n/messages/en/reviews.json
+++ b/frontend/src/lib/i18n/messages/en/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "Nothing to review for this job",
+ "reviews.empty.desc": "This translation has no stored segments: either it failed before any text was translated, or it is no longer retained by the server. You can remove it from your recent translations list."
}
diff --git a/frontend/src/lib/i18n/messages/en/translate.json b/frontend/src/lib/i18n/messages/en/translate.json
index e7f0b3d..4e2b8f2 100644
--- a/frontend/src/lib/i18n/messages/en/translate.json
+++ b/frontend/src/lib/i18n/messages/en/translate.json
@@ -96,6 +96,8 @@
"translate.downloadFailed": "The download failed. Please try again.",
"translate.recent.title": "Recent translations",
"translate.recent.review": "Review",
+ "translate.recent.remove": "Remove from history",
+ "translate.recent.removeConfirm": "Click again to confirm",
"translate.upload.ariaDropzone": "Upload a document: drag and drop, or press Enter to browse files",
"translate.cancelAction": "Cancel translation",
"translate.cancelledTitle": "Translation cancelled",
diff --git a/frontend/src/lib/i18n/messages/es/reviews.json b/frontend/src/lib/i18n/messages/es/reviews.json
index b780720..09d4b40 100644
--- a/frontend/src/lib/i18n/messages/es/reviews.json
+++ b/frontend/src/lib/i18n/messages/es/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "Nada que revisar en este trabajo",
+ "reviews.empty.desc": "Esta traducción no tiene segmentos guardados: o falló antes de traducir el texto, o el servidor ya no la conserva. Puede quitarla de la lista de traducciones recientes."
}
diff --git a/frontend/src/lib/i18n/messages/fa/reviews.json b/frontend/src/lib/i18n/messages/fa/reviews.json
index b780720..0b3f4ff 100644
--- a/frontend/src/lib/i18n/messages/fa/reviews.json
+++ b/frontend/src/lib/i18n/messages/fa/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "بخشی برای بازبینی در این کار وجود ندارد",
+ "reviews.empty.desc": "این ترجمه بخش ذخیرهشدهای ندارد: یا پیش از ترجمهٔ متن ناموفق بوده، یا سرور دیگر آن را نگه نمیدارد. میتوانید آن را از فهرست ترجمههای اخیر حذف کنید."
}
diff --git a/frontend/src/lib/i18n/messages/fr/reviews.json b/frontend/src/lib/i18n/messages/fr/reviews.json
index e314dc0..38b3788 100644
--- a/frontend/src/lib/i18n/messages/fr/reviews.json
+++ b/frontend/src/lib/i18n/messages/fr/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Rechercher dans les segments...",
"reviews.shortcutHint": "Ctrl + Entrée pour enregistrer",
"reviews.approvingProgress": "Approbation ({current}/{total})…",
- "reviews.noMatchingSegments": "Aucun segment ne correspond à votre filtre."
+ "reviews.noMatchingSegments": "Aucun segment ne correspond à votre filtre.",
+ "reviews.empty.title": "Rien à relire pour ce travail",
+ "reviews.empty.desc": "Cette traduction n'a aucun segment enregistré : soit elle a échoué avant la traduction du texte, soit elle n'est plus conservée par le serveur. Vous pouvez la retirer de la liste des traductions récentes."
}
diff --git a/frontend/src/lib/i18n/messages/fr/translate.json b/frontend/src/lib/i18n/messages/fr/translate.json
index 8a58303..7968c29 100644
--- a/frontend/src/lib/i18n/messages/fr/translate.json
+++ b/frontend/src/lib/i18n/messages/fr/translate.json
@@ -96,6 +96,8 @@
"translate.downloadFailed": "Le téléchargement a échoué. Réessayez.",
"translate.recent.title": "Traductions récentes",
"translate.recent.review": "Relire",
+ "translate.recent.remove": "Retirer de l'historique",
+ "translate.recent.removeConfirm": "Cliquez encore pour confirmer",
"translate.upload.ariaDropzone": "Déposer un document : glisser-déposer ou appuyer sur Entrée pour parcourir",
"translate.cancelAction": "Annuler la traduction",
"translate.cancelledTitle": "Traduction annulée",
diff --git a/frontend/src/lib/i18n/messages/it/reviews.json b/frontend/src/lib/i18n/messages/it/reviews.json
index b780720..786ab8a 100644
--- a/frontend/src/lib/i18n/messages/it/reviews.json
+++ b/frontend/src/lib/i18n/messages/it/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "Niente da rivedere per questo lavoro",
+ "reviews.empty.desc": "Questa traduzione non ha segmenti salvati: o è fallita prima di tradurre il testo, o il server non la conserva più. Puoi rimuoverla dall'elenco delle traduzioni recenti."
}
diff --git a/frontend/src/lib/i18n/messages/ja/reviews.json b/frontend/src/lib/i18n/messages/ja/reviews.json
index b780720..7dae8de 100644
--- a/frontend/src/lib/i18n/messages/ja/reviews.json
+++ b/frontend/src/lib/i18n/messages/ja/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "このジョブに校正するセグメントはありません",
+ "reviews.empty.desc": "この翻訳には保存されたセグメントがありません。テキストの翻訳前に失敗したか、サーバーに保持されていません。最近の翻訳リストから削除できます。"
}
diff --git a/frontend/src/lib/i18n/messages/ko/reviews.json b/frontend/src/lib/i18n/messages/ko/reviews.json
index b780720..303eb61 100644
--- a/frontend/src/lib/i18n/messages/ko/reviews.json
+++ b/frontend/src/lib/i18n/messages/ko/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "이 작업에 검토할 세그먼트가 없습니다",
+ "reviews.empty.desc": "이 번역에는 저장된 세그먼트가 없습니다. 텍스트 번역 전에 실패했거나 서버에 더 이상 보관되지 않습니다. 최근 번역 목록에서 제거할 수 있습니다."
}
diff --git a/frontend/src/lib/i18n/messages/nl/reviews.json b/frontend/src/lib/i18n/messages/nl/reviews.json
index b780720..56ce3b0 100644
--- a/frontend/src/lib/i18n/messages/nl/reviews.json
+++ b/frontend/src/lib/i18n/messages/nl/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "Niets te controleren voor deze opdracht",
+ "reviews.empty.desc": "Deze vertaling heeft geen opgeslagen segmenten: of hij mislukte voordat er tekst werd vertaald, of de server bewaart hem niet meer. U kunt hem verwijderen uit de lijst met recente vertalingen."
}
diff --git a/frontend/src/lib/i18n/messages/pt/reviews.json b/frontend/src/lib/i18n/messages/pt/reviews.json
index b780720..5861bcf 100644
--- a/frontend/src/lib/i18n/messages/pt/reviews.json
+++ b/frontend/src/lib/i18n/messages/pt/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "Nada a revisar neste trabalho",
+ "reviews.empty.desc": "Esta tradução não tem segmentos armazenados: ou falhou antes de qualquer texto ser traduzido, ou o servidor já não a conserva. Pode removê-la da lista de traduções recentes."
}
diff --git a/frontend/src/lib/i18n/messages/ru/reviews.json b/frontend/src/lib/i18n/messages/ru/reviews.json
index b780720..1520a34 100644
--- a/frontend/src/lib/i18n/messages/ru/reviews.json
+++ b/frontend/src/lib/i18n/messages/ru/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "Для этого задания нет сегментов для проверки",
+ "reviews.empty.desc": "У этого перевода нет сохранённых сегментов: либо он завершился ошибкой до перевода текста, либо сервер его больше не хранит. Вы можете удалить его из списка недавних переводов."
}
diff --git a/frontend/src/lib/i18n/messages/zh/reviews.json b/frontend/src/lib/i18n/messages/zh/reviews.json
index b780720..a0ba782 100644
--- a/frontend/src/lib/i18n/messages/zh/reviews.json
+++ b/frontend/src/lib/i18n/messages/zh/reviews.json
@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…",
- "reviews.noMatchingSegments": "No segments match your filter."
+ "reviews.noMatchingSegments": "No segments match your filter.",
+ "reviews.empty.title": "此任务没有可校对的段落",
+ "reviews.empty.desc": "此翻译没有已存储的段落:可能是在翻译文本之前失败,或服务器已不再保留。您可以将其从最近翻译列表中移除。"
}
diff --git a/routes/translate_routes.py b/routes/translate_routes.py
index 1d1af41..9ba2f50 100644
--- a/routes/translate_routes.py
+++ b/routes/translate_routes.py
@@ -71,7 +71,7 @@ from utils.file_handler import FileHandler
from middleware.metrics import record_translation, record_file_size
from services.progress_tracker import ProgressTracker
from services.storage_tracker import storage_tracker
-from core.redis import set_job_status_async, get_job_status_async
+from core.redis import set_job_status_async, get_job_status_async, delete_job_status_async
from services.glossary_service import get_glossary_terms, validate_glossary_access, build_full_prompt
from services.prompt_service import get_prompt_content, validate_prompt_access
from utils.exceptions import GlossaryNotFoundError, PromptNotFoundError
@@ -2257,6 +2257,92 @@ async def cancel_translation(
return {"data": {"id": job_id, "status": "cancelled"}, "meta": {}}
+@router_v1.delete(
+ "/translations/{job_id}",
+ responses={
+ 200: {"description": "Job removed from the user's history (idempotent)"},
+ 401: {"description": "Authentication required"},
+ 404: {"description": "Job belongs to another user"},
+ 409: {"description": "Job is queued or processing — cancel it first"},
+ },
+)
+async def delete_translation(
+ job_id: str,
+ current_user: Optional[Any] = Depends(get_authenticated_user),
+):
+ """
+ Remove a translation job from the user's history.
+
+ Deletes the in-memory job record, its Redis status key and its stored
+ review segments. Idempotent: a job the server has already forgotten
+ (retention expiry, restart) still returns 200 so the client can clean
+ its own list. Only the owner may delete — the share token does not
+ grant deletion rights. Files on disk are left to the TTL cleanup.
+ """
+ if current_user is None:
+ return JSONResponse(
+ status_code=401,
+ content={"error": "AUTH_REQUIRED", "message": "Authentication required."},
+ )
+ user_id = str(getattr(current_user, "id", ""))
+
+ job = _translation_jobs.get(job_id)
+ if not job:
+ job = await get_job_status_async(job_id)
+
+ if job:
+ # Do not reveal whether someone else's job exists: same 404 body.
+ if str(job.get("user_id", "")) != user_id:
+ return JSONResponse(
+ status_code=404,
+ content={
+ "error": "NOT_FOUND",
+ "message": "Translation job not found.",
+ "details": {"job_id": job_id},
+ },
+ )
+ if job.get("status") in ("queued", "processing"):
+ return JSONResponse(
+ status_code=409,
+ content={
+ "error": "JOB_IN_PROGRESS",
+ "message": "Cancel the job before deleting it.",
+ "details": {"job_id": job_id, "status": job.get("status")},
+ },
+ )
+
+ _translation_jobs.pop(job_id, None)
+ await delete_job_status_async(job_id)
+
+ def _delete_segments():
+ from database.connection import get_sync_session
+ from database.models import TranslationSegment
+
+ with get_sync_session() as session:
+ deleted = (
+ session.query(TranslationSegment)
+ .filter(TranslationSegment.job_id == job_id)
+ .delete()
+ )
+ session.commit()
+ return deleted
+
+ segments_deleted = 0
+ try:
+ segments_deleted = await asyncio.to_thread(_delete_segments)
+ except Exception as seg_err:
+ logger.warning(f"Job {job_id}: segment deletion failed: {seg_err}")
+
+ logger.info(
+ f"Job {job_id}: deleted from history by user "
+ f"(segments removed: {segments_deleted})"
+ )
+ return {
+ "data": {"id": job_id, "deleted": True, "segments_removed": segments_deleted},
+ "meta": {},
+ }
+
+
@router_v1.get("/translate/health")
async def translate_health():
"""Health check for translation endpoint."""
diff --git a/tests/test_delete_translation_history.py b/tests/test_delete_translation_history.py
new file mode 100644
index 0000000..3278f89
--- /dev/null
+++ b/tests/test_delete_translation_history.py
@@ -0,0 +1,144 @@
+"""DELETE /api/v1/translations/{job_id} — retrait d'un travail de l'historique.
+
+Contexte : un travail échoué ou oublié par le serveur (rétention 24 h en
+mémoire) restait affiché dans « Traductions récentes » sans aucun moyen de
+le retirer, et sa page de relecture était vide. La route supprime le
+travail de la mémoire, sa clé Redis et ses segments de relecture.
+"""
+
+import pytest
+from fastapi.testclient import TestClient
+from unittest.mock import patch
+
+from main import app
+from routes.translate_routes import get_authenticated_user, _translation_jobs
+
+
+@pytest.fixture()
+def client(monkeypatch):
+ from middleware.rate_limiting import RateLimitMiddleware
+
+ async def _dispatch(self, request, call_next):
+ return await call_next(request)
+
+ monkeypatch.setattr(RateLimitMiddleware, "dispatch", _dispatch)
+ return TestClient(app)
+
+
+class MockUser:
+ def __init__(self, user_id="user_123"):
+ self.id = user_id
+ self.plan = "free"
+ self.docs_translated_this_month = 0
+ self.pages_translated_this_month = 0
+ self.extra_credits = 0
+
+
+def _make_job(job_id="tr_del1", user_id="user_123", status="completed"):
+ return {
+ "id": job_id,
+ "user_id": user_id,
+ "status": status,
+ "file_name": "doc.docx",
+ "created_at": "2026-09-01T10:00:00",
+ "progress_percent": 100,
+ }
+
+
+@pytest.fixture(autouse=True)
+def _clean_jobs():
+ saved = dict(_translation_jobs)
+ _translation_jobs.clear()
+ yield
+ _translation_jobs.clear()
+ _translation_jobs.update(saved)
+
+
+def _seed_segments(job_id, user_id="user_123", count=2):
+ from database.models import Base, TranslationSegment
+ from database.connection import sync_engine, get_sync_session
+
+ Base.metadata.create_all(bind=sync_engine)
+ with get_sync_session() as session:
+ for i in range(count):
+ session.add(
+ TranslationSegment(
+ job_id=job_id,
+ user_id=user_id,
+ segment_index=i,
+ source_text=f"Hello {i}",
+ translated_text=f"Bonjour {i}",
+ status="pending",
+ )
+ )
+ session.commit()
+
+
+class TestDeleteTranslationHistory:
+ def test_owner_deletes_completed_job(self, client):
+ app.dependency_overrides[get_authenticated_user] = _async_user(MockUser())
+ _translation_jobs["tr_del1"] = _make_job()
+ _seed_segments("tr_del1")
+
+ res = client.delete("/api/v1/translations/tr_del1")
+ assert res.status_code == 200
+ body = res.json()["data"]
+ assert body["deleted"] is True
+ assert body["segments_removed"] == 2
+ assert "tr_del1" not in _translation_jobs
+
+ from database.connection import get_sync_session
+ from database.models import TranslationSegment
+
+ with get_sync_session() as session:
+ remaining = (
+ session.query(TranslationSegment)
+ .filter(TranslationSegment.job_id == "tr_del1")
+ .count()
+ )
+ assert remaining == 0
+ app.dependency_overrides.clear()
+
+ def test_delete_is_idempotent_when_job_already_gone(self, client):
+ app.dependency_overrides[get_authenticated_user] = _async_user(MockUser())
+ # Le serveur a oublié le travail (rétention expirée / redémarrage) :
+ # le client veut quand même nettoyer sa liste locale.
+ res = client.delete("/api/v1/translations/tr_unknown")
+ assert res.status_code == 200
+ assert res.json()["data"]["deleted"] is True
+ app.dependency_overrides.clear()
+
+ def test_other_users_job_is_404(self, client):
+ app.dependency_overrides[get_authenticated_user] = _async_user(MockUser())
+ _translation_jobs["tr_someone"] = _make_job("tr_someone", user_id="user_999")
+ res = client.delete("/api/v1/translations/tr_someone")
+ assert res.status_code == 404
+ # Le travail reste intact
+ assert "tr_someone" in _translation_jobs
+ app.dependency_overrides.clear()
+
+ def test_running_job_requires_cancel_first(self, client):
+ app.dependency_overrides[get_authenticated_user] = _async_user(MockUser())
+ _translation_jobs["tr_run"] = _make_job("tr_run", status="processing")
+ res = client.delete("/api/v1/translations/tr_run")
+ assert res.status_code == 409
+ assert res.json()["error"] == "JOB_IN_PROGRESS"
+ assert "tr_run" in _translation_jobs
+ app.dependency_overrides.clear()
+
+ def test_unauthenticated_is_401(self, client):
+ async def _anonymous():
+ return None
+
+ app.dependency_overrides[get_authenticated_user] = _anonymous
+ _translation_jobs["tr_anon"] = _make_job()
+ res = client.delete("/api/v1/translations/tr_anon")
+ assert res.status_code == 401
+ app.dependency_overrides.clear()
+
+
+def _async_user(user):
+ async def _auth():
+ return user
+
+ return _auth