- Extract database schema and migrations into @openreader/database - Extract startup and orchestration scripts into @openreader/bootstrap - Move compute-worker into packages/compute-worker - Remove runtime dependency on drizzle-kit - Update Dockerfile to deploy isolated packages without merging node_modules
162 lines
5.3 KiB
TypeScript
162 lines
5.3 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { and, inArray } from 'drizzle-orm';
|
|
import { db } from '@openreader/database';
|
|
import { documents } from '@openreader/database/schema';
|
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
|
import { toDocumentTypeFromName } from '@/lib/server/documents/utils';
|
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
|
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
|
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
|
import { deleteOwnedDocument } from '@/lib/server/documents/delete-owned';
|
|
import type { BaseDocument, DocumentType } from '@/types/documents';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
function s3NotConfiguredResponse(): NextResponse {
|
|
return NextResponse.json(
|
|
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
|
|
function normalizeDocumentType(rawType: unknown, safeName: string): DocumentType {
|
|
if (rawType === 'pdf' || rawType === 'epub' || rawType === 'docx' || rawType === 'html') {
|
|
return rawType;
|
|
}
|
|
return toDocumentTypeFromName(safeName);
|
|
}
|
|
|
|
export async function GET(req: NextRequest) {
|
|
try {
|
|
if (!isS3Configured()) return s3NotConfiguredResponse();
|
|
|
|
const ctxOrRes = await requireAuthContext(req);
|
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
const storageUserId = ctxOrRes.userId;
|
|
const allowedUserIds = [storageUserId];
|
|
|
|
const url = new URL(req.url);
|
|
const idsParam = url.searchParams.get('ids');
|
|
const targetIds = idsParam
|
|
? idsParam
|
|
.split(',')
|
|
.map((id) => id.trim().toLowerCase())
|
|
.filter((id) => isValidDocumentId(id))
|
|
: null;
|
|
|
|
if (idsParam && (!targetIds || targetIds.length === 0)) {
|
|
return NextResponse.json({ documents: [] });
|
|
}
|
|
|
|
const conditions = [
|
|
inArray(documents.userId, allowedUserIds),
|
|
...(targetIds && targetIds.length > 0 ? [inArray(documents.id, targetIds)] : []),
|
|
];
|
|
const rows = (await db.select().from(documents).where(and(...conditions))) as Array<{
|
|
id: string;
|
|
userId: string;
|
|
name: string;
|
|
type: string;
|
|
size: number;
|
|
lastModified: number;
|
|
filePath: string;
|
|
}>;
|
|
|
|
const results: BaseDocument[] = rows.map((doc) => {
|
|
const type = normalizeDocumentType(doc.type, doc.name);
|
|
return {
|
|
id: doc.id,
|
|
name: doc.name,
|
|
size: Number(doc.size),
|
|
lastModified: Number(doc.lastModified),
|
|
type,
|
|
scope: 'user',
|
|
};
|
|
});
|
|
|
|
return NextResponse.json({ documents: results });
|
|
} catch (error) {
|
|
serverLogger.error({
|
|
event: 'documents.list.failed',
|
|
error: errorToLog(error),
|
|
}, 'Failed to load document metadata');
|
|
return errorResponse(error, {
|
|
apiErrorMessage: 'Failed to load documents',
|
|
normalize: { code: 'DOCUMENTS_LIST_FAILED', errorClass: 'db' },
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function DELETE(req: NextRequest) {
|
|
try {
|
|
if (!isS3Configured()) return s3NotConfiguredResponse();
|
|
|
|
const ctxOrRes = await requireAuthContext(req);
|
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
|
const storageUserId = ctxOrRes.userId;
|
|
|
|
const url = new URL(req.url);
|
|
const idsParam = url.searchParams.get('ids');
|
|
const scopeParam = (url.searchParams.get('scope') || '').toLowerCase().trim();
|
|
if (scopeParam && scopeParam !== 'user') {
|
|
return NextResponse.json({ error: "Invalid scope. Expected 'user' (default)." }, { status: 400 });
|
|
}
|
|
|
|
const targetUserIds = [storageUserId];
|
|
|
|
let targetIds: string[] = [];
|
|
if (idsParam) {
|
|
targetIds = idsParam
|
|
.split(',')
|
|
.map((id) => id.trim().toLowerCase())
|
|
.filter((id) => isValidDocumentId(id));
|
|
} else {
|
|
const rows = (await db
|
|
.select({ id: documents.id })
|
|
.from(documents)
|
|
.where(inArray(documents.userId, targetUserIds))) as Array<{ id: string }>;
|
|
targetIds = rows.map((row) => row.id);
|
|
}
|
|
|
|
if (targetIds.length === 0) {
|
|
return NextResponse.json({ success: true, deleted: 0 });
|
|
}
|
|
|
|
const ownedRows = (await db
|
|
.select({ id: documents.id, userId: documents.userId })
|
|
.from(documents)
|
|
.where(and(inArray(documents.userId, targetUserIds), inArray(documents.id, targetIds)))) as Array<{
|
|
id: string;
|
|
userId: string;
|
|
}>;
|
|
|
|
let deleted = 0;
|
|
for (const row of ownedRows) {
|
|
if (await deleteOwnedDocument({
|
|
userId: row.userId,
|
|
documentId: row.id,
|
|
namespace: testNamespace,
|
|
})) {
|
|
deleted += 1;
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ success: true, deleted });
|
|
} catch (error) {
|
|
serverLogger.error({
|
|
event: 'documents.delete.failed',
|
|
error: errorToLog(error),
|
|
}, 'Failed to delete documents');
|
|
return errorResponse(error, {
|
|
apiErrorMessage: 'Failed to delete documents',
|
|
normalize: { code: 'DOCUMENTS_DELETE_FAILED', errorClass: 'db' },
|
|
});
|
|
}
|
|
}
|