feat: contract analysis API improvements and DSGVO compliance updates

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (LegalAI)
2026-04-09 07:56:01 +00:00
parent 0daf65ce91
commit 7dfbc42b8c
7 changed files with 322 additions and 234 deletions

View File

@@ -70,10 +70,10 @@ CREATE INDEX contract_clauses_rating_idx ON contract_clauses(rating);
ALTER TABLE contract_documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY contract_documents_tenant_isolation ON contract_documents
USING (tenant_id = current_setting('app.tenant_id')::uuid);
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE POLICY contract_documents_tenant_insert ON contract_documents
FOR INSERT WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
FOR INSERT WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- RLS policies for contract_clauses (via document join)
ALTER TABLE contract_clauses ENABLE ROW LEVEL SECURITY;
@@ -81,7 +81,11 @@ ALTER TABLE contract_clauses ENABLE ROW LEVEL SECURITY;
CREATE POLICY contract_clauses_tenant_isolation ON contract_clauses
USING (document_id IN (
SELECT id FROM contract_documents
WHERE tenant_id = current_setting('app.tenant_id')::uuid
WHERE tenant_id = current_setting('app.tenant_id', true)::uuid
));
-- Force RLS for the app role (even table owners are subject to policies)
ALTER TABLE contract_documents FORCE ROW LEVEL SECURITY;
ALTER TABLE contract_clauses FORCE ROW LEVEL SECURITY;
-- Standard clauses are shared reference data (no RLS needed)

View File

@@ -1,9 +1,11 @@
// GET /api/analyses/:id — Retrieve a single analysis with its sources
import { type NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { withTenantDb } from '@/lib/db';
import { analyses, norms, normInstruments, decisions } from '@/lib/db/schema';
import { eq, inArray } from 'drizzle-orm';
import { logAuditEvent } from '@/lib/auth/audit';
import type { TenantContext } from '@/lib/auth';
export async function GET(
request: NextRequest,
@@ -16,60 +18,72 @@ export async function GET(
return Response.json({ error: 'Missing x-tenant-id header' }, { status: 401 });
}
const [analysis] = await db
.select()
.from(analyses)
.where(eq(analyses.id, id))
.limit(1);
// RLS enforces tenant isolation — only rows matching app.tenant_id are visible.
const result = await withTenantDb(tenantId, async (tdb) => {
const [analysis] = await tdb
.select()
.from(analyses)
.where(eq(analyses.id, id))
.limit(1);
if (!analysis) {
if (!analysis) return null;
// Fetch referenced norms and decisions
const sources = analysis.sources as {
normIds: string[];
decisionIds: string[];
otherSources: string[];
} | null;
let referencedNorms: any[] = [];
let referencedDecisions: any[] = [];
if (sources?.normIds?.length) {
referencedNorms = await tdb
.select({
id: norms.id,
paragraph: norms.paragraph,
title: norms.title,
instrumentAbbreviation: normInstruments.abbreviation,
sourceRank: normInstruments.sourceRank,
})
.from(norms)
.innerJoin(normInstruments, eq(norms.instrumentId, normInstruments.id))
.where(inArray(norms.id, sources.normIds));
}
if (sources?.decisionIds?.length) {
referencedDecisions = await tdb
.select({
id: decisions.id,
caseReference: decisions.caseReference,
court: decisions.court,
decisionDate: decisions.decisionDate,
headnote: decisions.headnote,
})
.from(decisions)
.where(inArray(decisions.id, sources.decisionIds));
}
return { analysis, referencedNorms, referencedDecisions };
});
if (!result) {
return Response.json({ error: 'Analysis not found' }, { status: 404 });
}
if (analysis.tenantId !== tenantId) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
// Fetch referenced norms and decisions
const sources = analysis.sources as {
normIds: string[];
decisionIds: string[];
otherSources: string[];
} | null;
let referencedNorms: any[] = [];
let referencedDecisions: any[] = [];
if (sources?.normIds?.length) {
referencedNorms = await db
.select({
id: norms.id,
paragraph: norms.paragraph,
title: norms.title,
instrumentAbbreviation: normInstruments.abbreviation,
sourceRank: normInstruments.sourceRank,
})
.from(norms)
.innerJoin(normInstruments, eq(norms.instrumentId, normInstruments.id))
.where(inArray(norms.id, sources.normIds));
}
if (sources?.decisionIds?.length) {
referencedDecisions = await db
.select({
id: decisions.id,
caseReference: decisions.caseReference,
court: decisions.court,
decisionDate: decisions.decisionDate,
headnote: decisions.headnote,
})
.from(decisions)
.where(inArray(decisions.id, sources.decisionIds));
}
const userId = request.headers.get('x-user-id') ?? 'unknown';
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(
{ tenantId, userId } as TenantContext,
'read', 'analysis', id, undefined, ip,
);
return Response.json({
...analysis,
referencedNorms,
referencedDecisions,
...result.analysis,
referencedNorms: result.referencedNorms,
referencedDecisions: result.referencedDecisions,
});
}

View File

@@ -2,25 +2,20 @@
// GET /api/analyses — List analyses for the current tenant
import { type NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { withTenantDb } from '@/lib/db';
import { analyses } from '@/lib/db/schema';
import { eq, desc } from 'drizzle-orm';
import { desc } from 'drizzle-orm';
import { runAnalysis } from '@/lib/ai/analysis';
import { AnalyseMode } from '@/types';
import { logAuditEvent } from '@/lib/auth/audit';
import { requirePermission } from '@/lib/auth/rbac';
const VALID_MODES = new Set(Object.values(AnalyseMode));
export async function POST(request: NextRequest) {
// TODO: Replace with real auth once DPO implements AIIA-19
const tenantId = request.headers.get('x-tenant-id');
const userId = request.headers.get('x-user-id');
if (!tenantId || !userId) {
return Response.json(
{ error: 'Missing x-tenant-id or x-user-id header' },
{ status: 401 },
);
}
const auth = await requirePermission('analyses:create');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const body = await request.json();
const { mode, title, query, caseId, normIds, decisionIds, stichtag } = body;
@@ -40,8 +35,8 @@ export async function POST(request: NextRequest) {
}
const { analysisId, stream } = await runAnalysis({
tenantId,
userId,
tenantId: ctx.tenantId,
userId: ctx.userId,
caseId,
mode,
title,
@@ -51,6 +46,11 @@ export async function POST(request: NextRequest) {
stichtag,
});
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(ctx, 'create', 'analysis', analysisId, { mode, title }, ip);
// Return streaming response with analysis ID in header
const response = stream.toTextStreamResponse();
response.headers.set('X-Analysis-Id', analysisId);
@@ -58,22 +58,39 @@ export async function POST(request: NextRequest) {
}
export async function GET(request: NextRequest) {
const tenantId = request.headers.get('x-tenant-id');
if (!tenantId) {
return Response.json({ error: 'Missing x-tenant-id header' }, { status: 401 });
}
const auth = await requirePermission('analyses:read');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100);
const offset = parseInt(searchParams.get('offset') ?? '0', 10);
const results = await db
.select()
.from(analyses)
.where(eq(analyses.tenantId, tenantId))
.orderBy(desc(analyses.createdAt))
.limit(limit)
.offset(offset);
// DSGVO Art. 5(1)(c) Datenminimierung: nur Metadaten zurückgeben.
// Vollständige Daten (query, result) nur über GET /api/analyses/[id].
// RLS enforces tenant isolation via app.tenant_id — no manual WHERE needed.
const results = await withTenantDb(ctx.tenantId, async (tdb) =>
tdb
.select({
id: analyses.id,
title: analyses.title,
mode: analyses.mode,
status: analyses.status,
createdAt: analyses.createdAt,
updatedAt: analyses.updatedAt,
})
.from(analyses)
.orderBy(desc(analyses.createdAt))
.limit(limit)
.offset(offset),
);
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(
ctx, 'list', 'analysis', null, { limit, offset }, ip,
);
return Response.json(results);
}

View File

@@ -1,41 +1,37 @@
// POST /api/contracts/:id/analyze — Trigger text extraction and clause analysis
import { type NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { withTenantDb } from '@/lib/db';
import { contractDocuments } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { extractDocumentText, analyzeContractClauses } from '@/lib/contracts';
import { logAuditEvent } from '@/lib/auth/audit';
import { requirePermission } from '@/lib/auth/rbac';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const tenantId = request.headers.get('x-tenant-id');
const userId = request.headers.get('x-user-id');
if (!tenantId || !userId) {
return Response.json(
{ error: 'Missing x-tenant-id or x-user-id header' },
{ status: 401 },
);
}
const auth = await requirePermission('analyses:create');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const { id } = await params;
const [doc] = await db
.select()
.from(contractDocuments)
.where(eq(contractDocuments.id, id))
.limit(1);
// RLS enforces tenant isolation — only documents belonging to ctx.tenantId are visible.
const doc = await withTenantDb(ctx.tenantId, async (tdb) => {
const [d] = await tdb
.select()
.from(contractDocuments)
.where(eq(contractDocuments.id, id))
.limit(1);
return d ?? null;
});
if (!doc) {
return Response.json({ error: 'Dokument nicht gefunden' }, { status: 404 });
}
if (doc.tenantId !== tenantId) {
return Response.json({ error: 'Zugriff verweigert' }, { status: 403 });
}
if (doc.status === 'analyzing' || doc.status === 'extracting') {
return Response.json(
{ error: 'Analyse läuft bereits', status: doc.status },
@@ -46,11 +42,18 @@ export async function POST(
try {
// Step 1: Extract text if not yet done
if (!doc.extractedText) {
await extractDocumentText(id);
await extractDocumentText(ctx.tenantId, id);
}
// Step 2: Run clause analysis
await analyzeContractClauses(id);
await analyzeContractClauses(ctx.tenantId, id);
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(
ctx, 'analyze', 'contract', id, undefined, ip,
);
return Response.json({
documentId: id,
@@ -59,14 +62,16 @@ export async function POST(
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Analyse fehlgeschlagen';
await db
.update(contractDocuments)
.set({
status: 'failed',
errorMessage: message,
updatedAt: new Date(),
})
.where(eq(contractDocuments.id, id));
await withTenantDb(ctx.tenantId, async (tdb) => {
await tdb
.update(contractDocuments)
.set({
status: 'failed',
errorMessage: message,
updatedAt: new Date(),
})
.where(eq(contractDocuments.id, id));
});
return Response.json({ error: message }, { status: 500 });
}

View File

@@ -2,6 +2,8 @@
import { type NextRequest } from 'next/server';
import { getContractAnalysis } from '@/lib/contracts';
import { logAuditEvent } from '@/lib/auth/audit';
import type { TenantContext } from '@/lib/auth';
export async function GET(
request: NextRequest,
@@ -13,15 +15,22 @@ export async function GET(
}
const { id } = await params;
const result = await getContractAnalysis(id);
// RLS enforces tenant isolation — getContractAnalysis uses withTenantDb internally.
const result = await getContractAnalysis(tenantId, id);
if (!result) {
return Response.json({ error: 'Dokument nicht gefunden' }, { status: 404 });
}
if (result.document.tenantId !== tenantId) {
return Response.json({ error: 'Zugriff verweigert' }, { status: 403 });
}
const userId = request.headers.get('x-user-id') ?? 'unknown';
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(
{ tenantId, userId } as TenantContext,
'read', 'contract', id, undefined, ip,
);
// Omit extracted text and storage path from response for security
const { storagePath, extractedText, ...documentMeta } = result.document;

View File

@@ -6,19 +6,15 @@ import {
uploadContractDocument,
listContractDocuments,
} from '@/lib/contracts';
import { logAuditEvent } from '@/lib/auth/audit';
import { requirePermission } from '@/lib/auth/rbac';
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10 MB
export async function POST(request: NextRequest) {
const tenantId = request.headers.get('x-tenant-id');
const userId = request.headers.get('x-user-id');
if (!tenantId || !userId) {
return Response.json(
{ error: 'Missing x-tenant-id or x-user-id header' },
{ status: 401 },
);
}
const auth = await requirePermission('cases:edit');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const formData = await request.formData();
const file = formData.get('file');
@@ -38,13 +34,22 @@ export async function POST(request: NextRequest) {
);
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
try {
const result = await uploadContractDocument(
tenantId,
userId,
ctx.tenantId,
ctx.userId,
file,
caseId ?? undefined,
);
await logAuditEvent(
ctx, 'create', 'contract', result.documentId, { fileName: file.name, caseId }, ip,
);
return Response.json(result, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
@@ -53,15 +58,22 @@ export async function POST(request: NextRequest) {
}
export async function GET(request: NextRequest) {
const tenantId = request.headers.get('x-tenant-id');
if (!tenantId) {
return Response.json({ error: 'Missing x-tenant-id header' }, { status: 401 });
}
const auth = await requirePermission('cases:read');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100);
const offset = parseInt(searchParams.get('offset') ?? '0', 10);
const documents = await listContractDocuments(tenantId, limit, offset);
const documents = await listContractDocuments(ctx.tenantId, limit, offset);
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(
ctx, 'list', 'contract', null, { limit, offset }, ip,
);
return Response.json(documents);
}

View File

@@ -1,7 +1,8 @@
// Contract Analysis Module — document upload, text extraction, clause analysis
// Handles PDF/DOCX text extraction and AI-powered clause identification
// All DB access uses withTenantDb() for RLS-based tenant isolation (DSGVO Art. 32)
import { db } from '@/lib/db';
import { withTenantDb } from '@/lib/db';
import {
contractDocuments,
contractClauses,
@@ -82,20 +83,23 @@ export async function uploadContractDocument(
const deleteAfter = new Date();
deleteAfter.setDate(deleteAfter.getDate() + 90);
const [doc] = await db
.insert(contractDocuments)
.values({
tenantId,
userId,
caseId: caseId ?? null,
filename: file.name,
mimeType: file.type,
fileSizeBytes: file.size,
storagePath: filePath,
status: 'uploaded',
deleteAfter,
})
.returning();
const doc = await withTenantDb(tenantId, async (tdb) => {
const [d] = await tdb
.insert(contractDocuments)
.values({
tenantId,
userId,
caseId: caseId ?? null,
filename: file.name,
mimeType: file.type,
fileSizeBytes: file.size,
storagePath: filePath,
status: 'uploaded',
deleteAfter,
})
.returning();
return d;
});
return {
documentId: doc.id,
@@ -108,19 +112,24 @@ export async function uploadContractDocument(
* Extract text from a contract document (PDF or DOCX).
* Updates the document record with extracted text.
*/
export async function extractDocumentText(documentId: string): Promise<string> {
const [doc] = await db
.select()
.from(contractDocuments)
.where(eq(contractDocuments.id, documentId))
.limit(1);
export async function extractDocumentText(tenantId: string, documentId: string): Promise<string> {
const doc = await withTenantDb(tenantId, async (tdb) => {
const [d] = await tdb
.select()
.from(contractDocuments)
.where(eq(contractDocuments.id, documentId))
.limit(1);
return d ?? null;
});
if (!doc) throw new Error('Dokument nicht gefunden');
await db
.update(contractDocuments)
.set({ status: 'extracting', updatedAt: new Date() })
.where(eq(contractDocuments.id, documentId));
await withTenantDb(tenantId, async (tdb) => {
await tdb
.update(contractDocuments)
.set({ status: 'extracting', updatedAt: new Date() })
.where(eq(contractDocuments.id, documentId));
});
const fs = await import('node:fs/promises');
const fileBuffer = await fs.readFile(doc.storagePath);
@@ -139,14 +148,16 @@ export async function extractDocumentText(documentId: string): Promise<string> {
text = result.value;
}
await db
.update(contractDocuments)
.set({
extractedText: text,
status: 'extracted',
updatedAt: new Date(),
})
.where(eq(contractDocuments.id, documentId));
await withTenantDb(tenantId, async (tdb) => {
await tdb
.update(contractDocuments)
.set({
extractedText: text,
status: 'extracted',
updatedAt: new Date(),
})
.where(eq(contractDocuments.id, documentId));
});
return text;
}
@@ -155,32 +166,41 @@ export async function extractDocumentText(documentId: string): Promise<string> {
* Run AI-powered clause analysis on an extracted contract document.
* Identifies clauses, categorizes them, compares with standards, and rates them.
*/
export async function analyzeContractClauses(documentId: string): Promise<void> {
const [doc] = await db
.select()
.from(contractDocuments)
.where(eq(contractDocuments.id, documentId))
.limit(1);
export async function analyzeContractClauses(tenantId: string, documentId: string): Promise<void> {
const doc = await withTenantDb(tenantId, async (tdb) => {
const [d] = await tdb
.select()
.from(contractDocuments)
.where(eq(contractDocuments.id, documentId))
.limit(1);
return d ?? null;
});
if (!doc) throw new Error('Dokument nicht gefunden');
if (!doc.extractedText) throw new Error('Text wurde noch nicht extrahiert');
await db
.update(contractDocuments)
.set({ status: 'analyzing', updatedAt: new Date() })
.where(eq(contractDocuments.id, documentId));
await withTenantDb(tenantId, async (tdb) => {
await tdb
.update(contractDocuments)
.set({ status: 'analyzing', updatedAt: new Date() })
.where(eq(contractDocuments.id, documentId));
});
// Fetch standard clauses for comparison context
const standards = await db
.select({
id: standardClauses.id,
category: standardClauses.category,
label: standardClauses.label,
body: standardClauses.body,
instrumentAbbr: normInstruments.abbreviation,
})
.from(standardClauses)
.innerJoin(normInstruments, eq(standardClauses.instrumentId, normInstruments.id));
// standard_clauses have no RLS (shared reference data), but we access via
// withTenantDb to keep all queries on the tenant-scoped connection.
const standards = await withTenantDb(tenantId, async (tdb) =>
tdb
.select({
id: standardClauses.id,
category: standardClauses.category,
label: standardClauses.label,
body: standardClauses.body,
instrumentAbbr: normInstruments.abbreviation,
})
.from(standardClauses)
.innerJoin(normInstruments, eq(standardClauses.instrumentId, normInstruments.id)),
);
const standardsContext = standards.length > 0
? standards
@@ -229,14 +249,16 @@ Antworte NUR mit einem JSON-Array von Klausel-Objekten. Kein Fließtext.`,
if (!jsonMatch) throw new Error('No JSON array found in AI response');
clauses = JSON.parse(jsonMatch[0]);
} catch {
await db
.update(contractDocuments)
.set({
status: 'failed',
errorMessage: 'KI-Antwort konnte nicht geparst werden',
updatedAt: new Date(),
})
.where(eq(contractDocuments.id, documentId));
await withTenantDb(tenantId, async (tdb) => {
await tdb
.update(contractDocuments)
.set({
status: 'failed',
errorMessage: 'KI-Antwort konnte nicht geparst werden',
updatedAt: new Date(),
})
.where(eq(contractDocuments.id, documentId));
});
return;
}
@@ -247,47 +269,51 @@ Antworte NUR mit einem JSON-Array von Klausel-Objekten. Kein Fließtext.`,
// Insert extracted clauses
const validRatings = new Set(['standard', 'abweichend', 'kritisch', 'unbekannt']);
for (const clause of clauses) {
const rating = validRatings.has(clause.rating) ? clause.rating : 'unbekannt';
const matchedStandardId = standardsByCategory.get(clause.category) ?? null;
await withTenantDb(tenantId, async (tdb) => {
for (const clause of clauses) {
const rating = validRatings.has(clause.rating) ? clause.rating : 'unbekannt';
const matchedStandardId = standardsByCategory.get(clause.category) ?? null;
await db.insert(contractClauses).values({
documentId,
category: clause.category,
extractedText: clause.extractedText,
standardClauseId: matchedStandardId,
rating: rating as 'standard' | 'abweichend' | 'kritisch' | 'unbekannt',
analysis: clause.analysis,
deviations: clause.deviations ?? [],
riskScore: Math.max(0, Math.min(100, clause.riskScore ?? 0)),
});
}
await tdb.insert(contractClauses).values({
documentId,
category: clause.category,
extractedText: clause.extractedText,
standardClauseId: matchedStandardId,
rating: rating as 'standard' | 'abweichend' | 'kritisch' | 'unbekannt',
analysis: clause.analysis,
deviations: clause.deviations ?? [],
riskScore: Math.max(0, Math.min(100, clause.riskScore ?? 0)),
});
}
await db
.update(contractDocuments)
.set({ status: 'completed', updatedAt: new Date() })
.where(eq(contractDocuments.id, documentId));
await tdb
.update(contractDocuments)
.set({ status: 'completed', updatedAt: new Date() })
.where(eq(contractDocuments.id, documentId));
});
}
/**
* Get a contract document with its analyzed clauses.
*/
export async function getContractAnalysis(documentId: string) {
const [doc] = await db
.select()
.from(contractDocuments)
.where(eq(contractDocuments.id, documentId))
.limit(1);
export async function getContractAnalysis(tenantId: string, documentId: string) {
return withTenantDb(tenantId, async (tdb) => {
const [doc] = await tdb
.select()
.from(contractDocuments)
.where(eq(contractDocuments.id, documentId))
.limit(1);
if (!doc) return null;
if (!doc) return null;
const clauses = await db
.select()
.from(contractClauses)
.where(eq(contractClauses.documentId, documentId))
.orderBy(contractClauses.category);
const clauses = await tdb
.select()
.from(contractClauses)
.where(eq(contractClauses.documentId, documentId))
.orderBy(contractClauses.category);
return { document: doc, clauses };
return { document: doc, clauses };
});
}
/**
@@ -298,19 +324,20 @@ export async function listContractDocuments(
limit = 20,
offset = 0,
) {
return db
.select({
id: contractDocuments.id,
filename: contractDocuments.filename,
mimeType: contractDocuments.mimeType,
fileSizeBytes: contractDocuments.fileSizeBytes,
status: contractDocuments.status,
caseId: contractDocuments.caseId,
createdAt: contractDocuments.createdAt,
})
.from(contractDocuments)
.where(eq(contractDocuments.tenantId, tenantId))
.orderBy(desc(contractDocuments.createdAt))
.limit(limit)
.offset(offset);
return withTenantDb(tenantId, async (tdb) =>
tdb
.select({
id: contractDocuments.id,
filename: contractDocuments.filename,
mimeType: contractDocuments.mimeType,
fileSizeBytes: contractDocuments.fileSizeBytes,
status: contractDocuments.status,
caseId: contractDocuments.caseId,
createdAt: contractDocuments.createdAt,
})
.from(contractDocuments)
.orderBy(desc(contractDocuments.createdAt))
.limit(limit)
.offset(offset),
);
}