openreader/src/app/api/documents/blob/upload/presign/route.ts
Richard R 16cada6129 feat(tasks): introduce scheduled task engine and admin UI for background jobs
Add a general-purpose scheduled task system with a persistent registry and status tracking, supporting background maintenance jobs such as orphaned blob reaping, expired upload cleanup, job event pruning, and TTS usage retention. Implement a new `scheduled_tasks` table, task engine, and handlers for each maintenance operation. Integrate an admin UI panel for monitoring, manual runs, and configuration of tasks. Update document and user data cleanup flows to delegate shared blob and preview deletion to the scheduled reaper. Add Vercel cron integration for serverless environments.

BREAKING CHANGE: Document and user storage cleanup now relies on background scheduled tasks for shared blob and preview deletion; immediate inline deletion is no longer performed.
2026-06-07 05:29:29 -06:00

99 lines
3.4 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
import { requireAuthContext } from '@/lib/server/auth/auth';
import {
presignTempPut,
} from '@/lib/server/documents/blobstore';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
type PresignUpload = {
contentType: string;
size: number;
};
function parseUploads(body: unknown): PresignUpload[] {
if (!body || typeof body !== 'object') return [];
const rawUploads = (body as { uploads?: unknown }).uploads;
if (!Array.isArray(rawUploads)) return [];
const uploads: PresignUpload[] = [];
for (const raw of rawUploads) {
if (!raw || typeof raw !== 'object') continue;
const rec = raw as Record<string, unknown>;
const contentType =
typeof rec.contentType === 'string' && rec.contentType.trim()
? rec.contentType.trim()
: 'application/octet-stream';
const size = Number.isFinite(rec.size) && Number(rec.size) >= 0 ? Number(rec.size) : 0;
uploads.push({ contentType, size });
}
return uploads;
}
export async function POST(req: NextRequest) {
try {
if (!isS3Configured()) {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const userId = ctxOrRes.userId;
const body = await req.json().catch(() => null);
const uploads = parseUploads(body);
if (uploads.length === 0) {
return NextResponse.json({ error: 'No valid uploads provided' }, { status: 400 });
}
const { maxUploadMb } = await getResolvedRuntimeConfig();
const maxUploadBytes = maxUploadMb * 1024 * 1024;
const oversized = uploads.find((upload) => upload.size > maxUploadBytes);
if (oversized) {
return NextResponse.json(
{
error: `Upload exceeds the maximum allowed size of ${maxUploadBytes} bytes`,
maxBytes: maxUploadBytes,
},
{ status: 413 },
);
}
const namespace = getOpenReaderTestNamespace(req.headers);
// Expired temp uploads are swept by the cleanup-temp-uploads scheduled task.
const signed = await Promise.all(
uploads.map(async (upload) => {
const token = randomUUID();
const res = await presignTempPut(token, userId, upload.contentType, namespace, {
contentLength: upload.size,
});
return {
token,
url: res.url,
headers: res.headers,
};
}),
);
return NextResponse.json({ uploads: signed });
} catch (error) {
serverLogger.error({
event: 'documents.blob.upload.presign.failed',
error: errorToLog(error),
}, 'Failed to create document upload signatures');
return errorResponse(error, {
apiErrorMessage: 'Failed to presign uploads',
normalize: { code: 'DOCUMENTS_BLOB_UPLOAD_PRESIGN_FAILED', errorClass: 'storage' },
});
}
}