feat: contract analysis API improvements and DSGVO compliance updates
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user