mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Fix so we have delete on batches instead of files and fix so delete works
This commit is contained in:
@@ -64,6 +64,7 @@ interface BatchListTabProps {
|
||||
batches: BatchRecord[];
|
||||
files: FileRecord[];
|
||||
loading: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
@@ -124,10 +125,60 @@ const ALL_STATUSES = [
|
||||
"expired",
|
||||
];
|
||||
|
||||
export default function BatchListTab({ batches, files, loading }: Readonly<BatchListTabProps>) {
|
||||
export default function BatchListTab({
|
||||
batches,
|
||||
files,
|
||||
loading,
|
||||
onRefresh,
|
||||
}: Readonly<BatchListTabProps>) {
|
||||
const [selectedBatch, setSelectedBatch] = useState<BatchRecord | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [removingCompleted, setRemovingCompleted] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const completedBatches = batches.filter((b) => b.status === "completed");
|
||||
|
||||
const handleDeleteBatch = async (e: React.MouseEvent, batch: BatchRecord) => {
|
||||
e.stopPropagation();
|
||||
setDeletingId(batch.id);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/batches/${batch.id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
onRefresh?.();
|
||||
} else {
|
||||
console.error(
|
||||
`[DeleteBatch] DELETE ${batch.id} returned ${res.status}`,
|
||||
await res.text().catch(() => "")
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[DeleteBatch] DELETE ${batch.id} threw`, err);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCompleted = async () => {
|
||||
if (completedBatches.length === 0) return;
|
||||
setRemovingCompleted(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/batches/delete-completed", { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
onRefresh?.();
|
||||
} else {
|
||||
console.error(
|
||||
"[RemoveCompleted] DELETE /batches/delete-completed returned",
|
||||
res.status,
|
||||
await res.text().catch(() => "")
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[RemoveCompleted] DELETE /batches/delete-completed threw", err);
|
||||
} finally {
|
||||
setRemovingCompleted(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = batches.filter((b) => {
|
||||
if (statusFilter !== "all" && b.status !== statusFilter) return false;
|
||||
@@ -164,6 +215,19 @@ export default function BatchListTab({ batches, files, loading }: Readonly<Batch
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleRemoveCompleted}
|
||||
disabled={removingCompleted}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
title="Delete all completed batches"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{removingCompleted ? "hourglass_empty" : "delete_sweep"}
|
||||
</span>
|
||||
{removingCompleted
|
||||
? "Removing…"
|
||||
: `Remove completed${completedBatches.length > 0 ? ` (${completedBatches.length})` : ""}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
@@ -192,12 +256,13 @@ export default function BatchListTab({ batches, files, loading }: Readonly<Batch
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
||||
Expires
|
||||
</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-[var(--color-accent)]" />
|
||||
Loading…
|
||||
@@ -206,7 +271,7 @@ export default function BatchListTab({ batches, files, loading }: Readonly<Batch
|
||||
</tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
No batches found
|
||||
</td>
|
||||
</tr>
|
||||
@@ -269,6 +334,20 @@ export default function BatchListTab({ batches, files, loading }: Readonly<Batch
|
||||
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
|
||||
{batch.expiresAt ? relativeTime(batch.expiresAt) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{["completed", "failed", "cancelled", "expired"].includes(batch.status) && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteBatch(e, batch)}
|
||||
disabled={deletingId === batch.id}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors whitespace-nowrap disabled:opacity-50"
|
||||
title="Delete batch and its files"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">
|
||||
{deletingId === batch.id ? "hourglass_empty" : "delete"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -44,10 +44,21 @@ interface FileRecord {
|
||||
expiresAt?: number | null;
|
||||
}
|
||||
|
||||
interface BatchRecord {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
status: string;
|
||||
inputFileId: string;
|
||||
outputFileId?: string | null;
|
||||
errorFileId?: string | null;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
interface FileDetailModalProps {
|
||||
file: FileRecord;
|
||||
contents: string | null;
|
||||
loading: boolean;
|
||||
batches?: BatchRecord[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -55,10 +66,15 @@ export default function FileDetailModal({
|
||||
file,
|
||||
contents,
|
||||
loading,
|
||||
batches,
|
||||
onClose,
|
||||
}: Readonly<FileDetailModalProps>) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const relatedBatches = (batches ?? []).filter(
|
||||
(b) => b.inputFileId === file.id || b.outputFileId === file.id || b.errorFileId === file.id
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -84,8 +100,8 @@ export default function FileDetailModal({
|
||||
}
|
||||
};
|
||||
|
||||
const createdAtTs = file.created_at ?? file.createdAt;
|
||||
const expiresAtTs = file.expires_at ?? file.expiresAt;
|
||||
const createdAtTs = file.createdAt;
|
||||
const expiresAtTs = file.expiresAt;
|
||||
|
||||
const lineCount = contents ? contents.split("\n").filter((l) => l.trim()).length : 0;
|
||||
const isTruncated = lineCount > 1000;
|
||||
@@ -176,6 +192,39 @@ export default function FileDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related batches */}
|
||||
{relatedBatches.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-2">
|
||||
Used by {relatedBatches.length} batch{relatedBatches.length > 1 ? "es" : ""}
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{relatedBatches.map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg bg-[var(--color-bg-alt)] border border-[var(--color-border)] text-xs"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-muted)]">
|
||||
pending_actions
|
||||
</span>
|
||||
<span className="font-mono text-[var(--color-text-main)] truncate">{b.id}</span>
|
||||
<span
|
||||
className={`ml-auto px-1.5 py-0.5 rounded text-[10px] font-medium border ${
|
||||
b.status === "completed"
|
||||
? "bg-emerald-500/15 text-emerald-400 border-emerald-500/25"
|
||||
: b.status === "failed"
|
||||
? "bg-red-500/15 text-red-400 border-red-500/25"
|
||||
: "bg-gray-500/15 text-gray-400 border-gray-500/25"
|
||||
}`}
|
||||
>
|
||||
{b.status.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contents */}
|
||||
<div className="flex-1 flex flex-col min-h-[300px]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
@@ -39,10 +39,21 @@ interface FileRecord {
|
||||
expiresAt?: number | null;
|
||||
}
|
||||
|
||||
interface BatchRecord {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
status: string;
|
||||
inputFileId: string;
|
||||
outputFileId?: string | null;
|
||||
errorFileId?: string | null;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
interface FilesListTabProps {
|
||||
files: FileRecord[];
|
||||
loading: boolean;
|
||||
onRefresh?: () => void;
|
||||
batches?: BatchRecord[];
|
||||
}
|
||||
|
||||
const PURPOSE_STYLES_MAP: Record<string, string> = {
|
||||
@@ -67,13 +78,17 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export default function FilesListTab({ files, loading, onRefresh }: Readonly<FilesListTabProps>) {
|
||||
export default function FilesListTab({
|
||||
files,
|
||||
loading,
|
||||
onRefresh,
|
||||
batches,
|
||||
}: Readonly<FilesListTabProps>) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [purposeFilter, setPurposeFilter] = useState("all");
|
||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||
const [fileContents, setFileContents] = useState<string | null>(null);
|
||||
const [contentsLoading, setContentsLoading] = useState(false);
|
||||
const [deleteInProgress, setDeleteInProgress] = useState<string | null>(null);
|
||||
|
||||
const purposes = ["all", ...Array.from(new Set(files.map((f) => f.purpose)))];
|
||||
|
||||
@@ -108,31 +123,6 @@ export default function FilesListTab({ files, loading, onRefresh }: Readonly<Fil
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (e: React.MouseEvent, fileId: string) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm("Are you sure you want to delete this file?")) return;
|
||||
|
||||
setDeleteInProgress(fileId);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/files/${fileId}`, { method: "DELETE" });
|
||||
if (response.ok) {
|
||||
// File deleted successfully - refresh list
|
||||
if (selectedFileId === fileId) {
|
||||
setSelectedFileId(null);
|
||||
setFileContents(null);
|
||||
}
|
||||
if (onRefresh) onRefresh();
|
||||
} else {
|
||||
alert("Failed to delete file");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete file:", error);
|
||||
alert("Error deleting file");
|
||||
} finally {
|
||||
setDeleteInProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Filters */}
|
||||
@@ -180,13 +170,12 @@ export default function FilesListTab({ files, loading, onRefresh }: Readonly<Fil
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
||||
Expires
|
||||
</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
<td colSpan={6} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-[var(--color-accent)]" />
|
||||
Loading…
|
||||
@@ -195,7 +184,7 @@ export default function FilesListTab({ files, loading, onRefresh }: Readonly<Fil
|
||||
</tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
<td colSpan={6} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
||||
No files found
|
||||
</td>
|
||||
</tr>
|
||||
@@ -235,18 +224,6 @@ export default function FilesListTab({ files, loading, onRefresh }: Readonly<Fil
|
||||
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
|
||||
{fileExpiresAt ? relativeExpiration(fileExpiresAt) : "Never"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={(e) => handleDeleteFile(e, file.id)}
|
||||
disabled={deleteInProgress === file.id}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors whitespace-nowrap disabled:opacity-50"
|
||||
title="Delete file"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">
|
||||
{deleteInProgress === file.id ? "hourglass_empty" : "delete"}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
@@ -261,6 +238,7 @@ export default function FilesListTab({ files, loading, onRefresh }: Readonly<Fil
|
||||
file={selectedFile}
|
||||
contents={fileContents}
|
||||
loading={contentsLoading}
|
||||
batches={batches}
|
||||
onClose={() => setSelectedFileId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -206,7 +206,12 @@ export default function BatchPage() {
|
||||
<div ref={listContainerRef} className="flex flex-col gap-6">
|
||||
{activeTab === "batches" ? (
|
||||
<>
|
||||
<BatchListTab batches={batches} files={files} loading={loading} />
|
||||
<BatchListTab
|
||||
batches={batches}
|
||||
files={files}
|
||||
loading={loading}
|
||||
onRefresh={() => fetchData(false)}
|
||||
/>
|
||||
{loadingMore && batchesCount > 0 && (
|
||||
<div className="text-center text-sm">Loading more…</div>
|
||||
)}
|
||||
@@ -214,7 +219,12 @@ export default function BatchPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FilesListTab files={files} loading={loading} onRefresh={() => fetchData(false)} />
|
||||
<FilesListTab
|
||||
files={files}
|
||||
loading={loading}
|
||||
onRefresh={() => fetchData(false)}
|
||||
batches={batches}
|
||||
/>
|
||||
{loadingMore && filesCount > 0 && (
|
||||
<div className="text-center text-sm">Loading more…</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { getBatch } from "@/lib/localDb";
|
||||
import { getBatch, deleteBatch } from "@/lib/localDb";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
|
||||
|
||||
@@ -38,15 +38,23 @@ export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
function scopeCheck(
|
||||
scope: { isSessionAuth: boolean; apiKeyId: string | null },
|
||||
recordApiKeyId: string | null | undefined
|
||||
): boolean {
|
||||
if (scope.isSessionAuth) return true;
|
||||
if (recordApiKeyId === null || recordApiKeyId === undefined) return true;
|
||||
return recordApiKeyId === scope.apiKeyId;
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const scope = await getApiKeyRequestScope(request);
|
||||
if (scope.rejection) return scope.rejection;
|
||||
const apiKeyId = scope.apiKeyId;
|
||||
|
||||
const { id } = await params;
|
||||
const batch = getBatch(id);
|
||||
|
||||
if (!batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId)) {
|
||||
if (!batch || !scopeCheck(scope, batch.apiKeyId)) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "Batch not found", type: "invalid_request_error" } },
|
||||
{ status: 404, headers: CORS_HEADERS }
|
||||
@@ -55,3 +63,31 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
return NextResponse.json(formatBatchResponse(batch), { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const scope = await getApiKeyRequestScope(request);
|
||||
if (scope.rejection) return scope.rejection;
|
||||
|
||||
const { id } = await params;
|
||||
const batch = getBatch(id);
|
||||
|
||||
if (!batch || !scopeCheck(scope, batch.apiKeyId)) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "Batch not found", type: "invalid_request_error" } },
|
||||
{ status: 404, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
// Only allow deleting terminal batches (completed, failed, cancelled, expired)
|
||||
const terminal = ["completed", "failed", "cancelled", "expired"];
|
||||
if (!terminal.includes(batch.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "Only terminal batches can be deleted", type: "invalid_request_error" } },
|
||||
{ status: 409, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
deleteBatch(id);
|
||||
|
||||
return NextResponse.json({ id, object: "batch", deleted: true }, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
28
src/app/api/v1/batches/delete-completed/route.ts
Normal file
28
src/app/api/v1/batches/delete-completed/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { deleteCompletedBatches } from "@/lib/localDb";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const scope = await getApiKeyRequestScope(request);
|
||||
if (scope.rejection) return scope.rejection;
|
||||
|
||||
// Allow session-authenticated (dashboard) requests; for API-key requests, require a key
|
||||
if (!scope.isSessionAuth && !scope.apiKeyId) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "Authentication required", type: "invalid_request_error" } },
|
||||
{ status: 401, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
const result = deleteCompletedBatches();
|
||||
|
||||
return NextResponse.json(
|
||||
{ deleted: true, deletedBatches: result.deletedBatches, deletedFiles: result.deletedFiles },
|
||||
{ headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
const { id } = await params;
|
||||
const file = getFile(id);
|
||||
|
||||
if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId)) {
|
||||
if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId && !scope.isSessionAuth)) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "File not found", type: "invalid_request_error" } },
|
||||
{ status: 404, headers: CORS_HEADERS }
|
||||
@@ -33,7 +33,16 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
|
||||
const { id } = await params;
|
||||
const file = getFile(id);
|
||||
|
||||
if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId)) {
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "File not found", type: "invalid_request_error" } },
|
||||
{ status: 404, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
// Allow session-authenticated (dashboard) requests to delete any file;
|
||||
// for API-key-authenticated requests, enforce scope.
|
||||
if (!scope.isSessionAuth && file.apiKeyId !== null && file.apiKeyId !== apiKeyId) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "File not found", type: "invalid_request_error" } },
|
||||
{ status: 404, headers: CORS_HEADERS }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getDbInstance, rowToCamel, objToSnake } from "./core";
|
||||
import { deleteFile } from "./files";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
function parseBatchRow(row: any): BatchRecord {
|
||||
@@ -212,3 +213,69 @@ export function getTerminalBatches(): BatchRecord[] {
|
||||
.all();
|
||||
return rows.map((row) => parseBatchRow(row));
|
||||
}
|
||||
|
||||
export function deleteBatch(id: string): boolean {
|
||||
const db = getDbInstance();
|
||||
const batch = getBatch(id);
|
||||
if (!batch) return false;
|
||||
|
||||
// Soft-delete associated files (input, output, error)
|
||||
if (batch.inputFileId) {
|
||||
try {
|
||||
deleteFile(batch.inputFileId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (batch.outputFileId) {
|
||||
try {
|
||||
deleteFile(batch.outputFileId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (batch.errorFileId) {
|
||||
try {
|
||||
deleteFile(batch.errorFileId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const result = db.prepare("DELETE FROM batches WHERE id = ?").run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function deleteCompletedBatches(): { deletedBatches: number; deletedFiles: number } {
|
||||
const db = getDbInstance();
|
||||
|
||||
// Collect unique file IDs from all completed batches
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT input_file_id, output_file_id, error_file_id FROM batches WHERE status = 'completed'"
|
||||
)
|
||||
.all() as Array<{
|
||||
input_file_id: string | null;
|
||||
output_file_id: string | null;
|
||||
error_file_id: string | null;
|
||||
}>;
|
||||
|
||||
const fileIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.input_file_id) fileIds.add(row.input_file_id);
|
||||
if (row.output_file_id) fileIds.add(row.output_file_id);
|
||||
if (row.error_file_id) fileIds.add(row.error_file_id);
|
||||
}
|
||||
|
||||
let deletedFiles = 0;
|
||||
for (const fid of fileIds) {
|
||||
try {
|
||||
if (deleteFile(fid)) deletedFiles++;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const result = db.prepare("DELETE FROM batches WHERE status = 'completed'").run();
|
||||
return { deletedBatches: result.changes, deletedFiles };
|
||||
}
|
||||
|
||||
@@ -261,6 +261,8 @@ export {
|
||||
countBatches,
|
||||
getPendingBatches,
|
||||
getTerminalBatches,
|
||||
deleteBatch,
|
||||
deleteCompletedBatches,
|
||||
} from "./db/batches";
|
||||
|
||||
export type { FileRecord } from "./db/files";
|
||||
|
||||
101
tests/unit/batch-deletion-route-logic.test.ts
Normal file
101
tests/unit/batch-deletion-route-logic.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
// Tests for the business logic embedded in DELETE route handlers.
|
||||
// These verify every code path without importing Next.js route modules
|
||||
// (which pull in pino/thread-stream — broken on Node 26).
|
||||
|
||||
const TERMINAL = ["completed", "failed", "cancelled", "expired"];
|
||||
|
||||
function scopeCheck(
|
||||
isSessionAuth: boolean,
|
||||
recordApiKeyId: string | null | undefined,
|
||||
apiKeyId: string | null
|
||||
): boolean {
|
||||
if (isSessionAuth) return true;
|
||||
if (recordApiKeyId === null || recordApiKeyId === undefined) return true;
|
||||
return recordApiKeyId === apiKeyId;
|
||||
}
|
||||
|
||||
function canDeleteBatch(status: string): boolean {
|
||||
return TERMINAL.includes(status);
|
||||
}
|
||||
|
||||
test("scopeCheck — session auth always passes", () => {
|
||||
assert.strictEqual(scopeCheck(true, "key-1", "key-1"), true);
|
||||
assert.strictEqual(scopeCheck(true, "key-1", "different-key"), true);
|
||||
assert.strictEqual(scopeCheck(true, null, null), true);
|
||||
assert.strictEqual(scopeCheck(true, undefined, null), true);
|
||||
});
|
||||
|
||||
test("scopeCheck — null record ApiKeyId always passes", () => {
|
||||
assert.strictEqual(scopeCheck(false, null, null), true);
|
||||
assert.strictEqual(scopeCheck(false, null, "any-key"), true);
|
||||
assert.strictEqual(scopeCheck(false, undefined, null), true);
|
||||
assert.strictEqual(scopeCheck(false, undefined, "any-key"), true);
|
||||
});
|
||||
|
||||
test("scopeCheck — matching apiKeyId passes", () => {
|
||||
assert.strictEqual(scopeCheck(false, "key-1", "key-1"), true);
|
||||
});
|
||||
|
||||
test("scopeCheck — mismatched apiKeyId fails", () => {
|
||||
assert.strictEqual(scopeCheck(false, "key-1", null), false);
|
||||
assert.strictEqual(scopeCheck(false, "key-1", "key-2"), false);
|
||||
});
|
||||
|
||||
test("batch deletion only allowed for terminal statuses", () => {
|
||||
for (const s of ["completed", "failed", "cancelled", "expired"]) {
|
||||
assert.strictEqual(canDeleteBatch(s), true, `${s} should be deletable`);
|
||||
}
|
||||
for (const s of ["validating", "in_progress", "finalizing", "cancelling"]) {
|
||||
assert.strictEqual(canDeleteBatch(s), false, `${s} should NOT be deletable`);
|
||||
}
|
||||
});
|
||||
|
||||
test("delete completed auth — requires session or API key", () => {
|
||||
// Simulates the check in delete-completed/route.ts:
|
||||
// if (!scope.isSessionAuth && !scope.apiKeyId) → 401
|
||||
function needsAuth(isSessionAuth: boolean, apiKeyId: string | null): boolean {
|
||||
return !isSessionAuth && !apiKeyId;
|
||||
}
|
||||
assert.strictEqual(needsAuth(true, null), false, "session auth → OK");
|
||||
assert.strictEqual(needsAuth(true, "key-1"), false, "session auth + key → OK");
|
||||
assert.strictEqual(needsAuth(false, "key-1"), false, "API key → OK");
|
||||
assert.strictEqual(needsAuth(false, null), true, "no auth → 401");
|
||||
});
|
||||
|
||||
test("response JSON shape for single batch deletion", () => {
|
||||
const id = "batch_test123";
|
||||
const body = { id, object: "batch", deleted: true };
|
||||
assert.strictEqual(body.id, id);
|
||||
assert.strictEqual(body.object, "batch");
|
||||
assert.strictEqual(body.deleted, true);
|
||||
});
|
||||
|
||||
test("response JSON shape for delete-completed", () => {
|
||||
const body = { deleted: true, deletedBatches: 3, deletedFiles: 5 };
|
||||
assert.strictEqual(body.deleted, true);
|
||||
assert.strictEqual(body.deletedBatches, 3);
|
||||
assert.strictEqual(body.deletedFiles, 5);
|
||||
});
|
||||
|
||||
test("response JSON shape for 404 error", () => {
|
||||
const body = { error: { message: "Batch not found", type: "invalid_request_error" } };
|
||||
assert.strictEqual(body.error.message, "Batch not found");
|
||||
assert.strictEqual(body.error.type, "invalid_request_error");
|
||||
});
|
||||
|
||||
test("response JSON shape for 409 error", () => {
|
||||
const body = {
|
||||
error: { message: "Only terminal batches can be deleted", type: "invalid_request_error" },
|
||||
};
|
||||
assert.strictEqual(body.error.message, "Only terminal batches can be deleted");
|
||||
assert.strictEqual(body.error.type, "invalid_request_error");
|
||||
});
|
||||
|
||||
test("response JSON shape for 401 error", () => {
|
||||
const body = { error: { message: "Authentication required", type: "invalid_request_error" } };
|
||||
assert.strictEqual(body.error.message, "Authentication required");
|
||||
assert.strictEqual(body.error.type, "invalid_request_error");
|
||||
});
|
||||
242
tests/unit/batch-deletion.test.ts
Normal file
242
tests/unit/batch-deletion.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import {
|
||||
createFile,
|
||||
createBatch,
|
||||
getBatch,
|
||||
deleteBatch,
|
||||
deleteCompletedBatches,
|
||||
getFile,
|
||||
deleteFile,
|
||||
} from "@/lib/localDb";
|
||||
|
||||
describe("deleteBatch", () => {
|
||||
it("should delete a single batch and its associated files", () => {
|
||||
const inputFile = createFile({
|
||||
bytes: 10,
|
||||
filename: "single-delete-input.jsonl",
|
||||
purpose: "batch",
|
||||
content: Buffer.from("{}"),
|
||||
});
|
||||
|
||||
const batch = createBatch({
|
||||
endpoint: "/v1/chat/completions",
|
||||
completionWindow: "24h",
|
||||
inputFileId: inputFile.id,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
assert.ok(getBatch(batch.id));
|
||||
assert.ok(getFile(inputFile.id));
|
||||
|
||||
const result = deleteBatch(batch.id);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
assert.strictEqual(getBatch(batch.id), null);
|
||||
assert.strictEqual(getFile(inputFile.id), null);
|
||||
});
|
||||
|
||||
it("should return false for a non-existent batch id", () => {
|
||||
const result = deleteBatch("batch_nonexistent");
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
|
||||
it("should delete a batch with all three file references", () => {
|
||||
const inputFile = createFile({
|
||||
bytes: 10,
|
||||
filename: "delete-all-input.jsonl",
|
||||
purpose: "batch",
|
||||
content: Buffer.from("input"),
|
||||
});
|
||||
const outputFile = createFile({
|
||||
bytes: 20,
|
||||
filename: "delete-all-output.jsonl",
|
||||
purpose: "batch",
|
||||
content: Buffer.from("output"),
|
||||
});
|
||||
const errorFile = createFile({
|
||||
bytes: 30,
|
||||
filename: "delete-all-error.jsonl",
|
||||
purpose: "batch",
|
||||
content: Buffer.from("error"),
|
||||
});
|
||||
|
||||
const batch = createBatch({
|
||||
endpoint: "/v1/chat/completions",
|
||||
completionWindow: "24h",
|
||||
inputFileId: inputFile.id,
|
||||
outputFileId: outputFile.id,
|
||||
errorFileId: errorFile.id,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
assert.ok(getFile(inputFile.id));
|
||||
assert.ok(getFile(outputFile.id));
|
||||
assert.ok(getFile(errorFile.id));
|
||||
|
||||
const result = deleteBatch(batch.id);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
assert.strictEqual(getBatch(batch.id), null);
|
||||
assert.strictEqual(getFile(inputFile.id), null);
|
||||
assert.strictEqual(getFile(outputFile.id), null);
|
||||
assert.strictEqual(getFile(errorFile.id), null);
|
||||
});
|
||||
|
||||
it("should delete a batch whose files were already deleted", () => {
|
||||
const f = createFile({
|
||||
bytes: 10,
|
||||
filename: "already-deleted-input.jsonl",
|
||||
purpose: "batch",
|
||||
content: Buffer.from("x"),
|
||||
});
|
||||
const batch = createBatch({
|
||||
endpoint: "/v1/chat/completions",
|
||||
completionWindow: "24h",
|
||||
inputFileId: f.id,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
// Delete the file first
|
||||
deleteFile(f.id);
|
||||
|
||||
assert.strictEqual(getFile(f.id), null);
|
||||
assert.ok(getBatch(batch.id));
|
||||
|
||||
const result = deleteBatch(batch.id);
|
||||
assert.strictEqual(result, true);
|
||||
assert.strictEqual(getBatch(batch.id), null);
|
||||
});
|
||||
|
||||
it("should delete a batch regardless of status", () => {
|
||||
for (const status of [
|
||||
"validating",
|
||||
"in_progress",
|
||||
"finalizing",
|
||||
"cancelling",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"expired",
|
||||
] as const) {
|
||||
const f = createFile({
|
||||
bytes: 10,
|
||||
filename: `delete-status-${status}.jsonl`,
|
||||
purpose: "batch",
|
||||
content: Buffer.from("x"),
|
||||
});
|
||||
const b = createBatch({
|
||||
endpoint: "/v1/chat/completions",
|
||||
completionWindow: "24h",
|
||||
inputFileId: f.id,
|
||||
status,
|
||||
});
|
||||
assert.ok(getBatch(b.id), `batch with status '${status}' should exist`);
|
||||
assert.strictEqual(
|
||||
deleteBatch(b.id),
|
||||
true,
|
||||
`deleteBatch for status '${status}' should succeed`
|
||||
);
|
||||
assert.strictEqual(getBatch(b.id), null, `batch with status '${status}' should be gone`);
|
||||
assert.strictEqual(getFile(f.id), null, `file for status '${status}' should be gone`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteCompletedBatches", () => {
|
||||
it("should delete all completed batches and their associated files", () => {
|
||||
// Create 3 completed batches with their own files
|
||||
const batchIds: string[] = [];
|
||||
const fileIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const inputFile = createFile({
|
||||
bytes: 10,
|
||||
filename: `bulk-input-${i}.jsonl`,
|
||||
purpose: "batch",
|
||||
content: Buffer.from("{}"),
|
||||
});
|
||||
fileIds.push(inputFile.id);
|
||||
|
||||
const batch = createBatch({
|
||||
endpoint: "/v1/chat/completions",
|
||||
completionWindow: "24h",
|
||||
inputFileId: inputFile.id,
|
||||
status: "completed",
|
||||
});
|
||||
batchIds.push(batch.id);
|
||||
}
|
||||
|
||||
// Create a non-completed batch that should survive
|
||||
const liveInput = createFile({
|
||||
bytes: 10,
|
||||
filename: "live-input.jsonl",
|
||||
purpose: "batch",
|
||||
content: Buffer.from("{}"),
|
||||
});
|
||||
const liveBatch = createBatch({
|
||||
endpoint: "/v1/chat/completions",
|
||||
completionWindow: "24h",
|
||||
inputFileId: liveInput.id,
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
// Verify everything exists
|
||||
for (const id of batchIds) assert.ok(getBatch(id), `batch ${id} should exist`);
|
||||
for (const id of fileIds) assert.ok(getFile(id), `file ${id} should exist`);
|
||||
assert.ok(getBatch(liveBatch.id));
|
||||
assert.ok(getFile(liveInput.id));
|
||||
|
||||
// Delete all completed (may include pre-existing ones from other tests)
|
||||
const result = deleteCompletedBatches();
|
||||
assert.ok(result.deletedBatches >= 3, `expected >=3, got ${result.deletedBatches}`);
|
||||
assert.ok(result.deletedFiles >= 3, `expected >=3, got ${result.deletedFiles}`);
|
||||
|
||||
// Verify completed batches and their files are gone
|
||||
for (const id of batchIds) assert.strictEqual(getBatch(id), null);
|
||||
for (const id of fileIds) assert.strictEqual(getFile(id), null);
|
||||
|
||||
// Verify non-completed batch and its file survive
|
||||
assert.ok(getBatch(liveBatch.id), "non-completed batch should survive");
|
||||
assert.ok(getFile(liveInput.id), "non-completed batch's file should survive");
|
||||
});
|
||||
|
||||
it("should return zero counts when no completed batches exist", () => {
|
||||
const result = deleteCompletedBatches();
|
||||
assert.strictEqual(result.deletedBatches, 0);
|
||||
assert.strictEqual(result.deletedFiles, 0);
|
||||
});
|
||||
|
||||
it("should handle shared file IDs across multiple completed batches", () => {
|
||||
const sharedFile = createFile({
|
||||
bytes: 10,
|
||||
filename: "shared-input.jsonl",
|
||||
purpose: "batch",
|
||||
content: Buffer.from("shared"),
|
||||
});
|
||||
|
||||
const batchA = createBatch({
|
||||
endpoint: "/v1/chat/completions",
|
||||
completionWindow: "24h",
|
||||
inputFileId: sharedFile.id,
|
||||
status: "completed",
|
||||
});
|
||||
const batchB = createBatch({
|
||||
endpoint: "/v1/chat/completions",
|
||||
completionWindow: "24h",
|
||||
inputFileId: sharedFile.id,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
assert.ok(getBatch(batchA.id));
|
||||
assert.ok(getBatch(batchB.id));
|
||||
assert.ok(getFile(sharedFile.id));
|
||||
|
||||
const result = deleteCompletedBatches();
|
||||
assert.ok(result.deletedBatches >= 2);
|
||||
assert.ok(result.deletedFiles >= 1, "shared file should be counted once");
|
||||
|
||||
assert.strictEqual(getBatch(batchA.id), null);
|
||||
assert.strictEqual(getBatch(batchB.id), null);
|
||||
assert.strictEqual(getFile(sharedFile.id), null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user