Compare commits
1 Commits
mai/curie/
...
mai/ritchi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f539102937 |
@@ -30,6 +30,20 @@ func main() {
|
||||
log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos")
|
||||
}
|
||||
|
||||
// Phase H: optional dependencies for document upload + AI extraction.
|
||||
// Both services degrade to a 501 response when their env vars are unset;
|
||||
// the UI hides their buttons based on GET /api/config/features.
|
||||
supabaseServiceKey := os.Getenv("SUPABASE_SERVICE_KEY")
|
||||
anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
storageClient := services.NewStorageClient(supabaseURL, supabaseServiceKey)
|
||||
aiService := services.NewAIService(anthropicAPIKey)
|
||||
if storageClient == nil {
|
||||
log.Println("SUPABASE_SERVICE_KEY not set — document upload/download disabled")
|
||||
}
|
||||
if aiService == nil {
|
||||
log.Println("ANTHROPIC_API_KEY not set — AI deadline extraction disabled")
|
||||
}
|
||||
|
||||
// DATABASE_URL is optional during the Phase A → Phase D transition. The
|
||||
// existing knowledge-platform features (Kostenrechner, Glossar, etc.) work
|
||||
// without a DB. Akten/Frist endpoints return 503 until DATABASE_URL is set.
|
||||
@@ -50,15 +64,18 @@ func main() {
|
||||
users := services.NewUserService(pool)
|
||||
akteSvc := services.NewAkteService(pool, users)
|
||||
rules := services.NewDeadlineRuleService(pool)
|
||||
fristSvc := services.NewFristService(pool, akteSvc)
|
||||
dokumentSvc := services.NewDokumentService(pool, storageClient, aiService, akteSvc, fristSvc)
|
||||
svcBundle = &handlers.Services{
|
||||
Akte: akteSvc,
|
||||
Parteien: services.NewParteienService(pool, akteSvc),
|
||||
Frist: services.NewFristService(pool, akteSvc),
|
||||
Frist: fristSvc,
|
||||
Rules: rules,
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
|
||||
Dashboard: services.NewDashboardService(pool, users),
|
||||
Dokument: dokumentSvc,
|
||||
}
|
||||
log.Println("Phase B services initialised")
|
||||
} else {
|
||||
|
||||
@@ -156,16 +156,95 @@ export function renderAktenDetail(): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Dokumente — Phase H placeholder */}
|
||||
{/* Dokumente — Phase H (upload + AI extraction) */}
|
||||
<section className="akten-tab-panel" id="tab-dokumente" style="display:none">
|
||||
<div className="akten-soon">
|
||||
<h2 data-i18n="akten.detail.soon">Bald verfügbar</h2>
|
||||
<p data-i18n="akten.detail.soon.dokumente">
|
||||
Dokumenten-Upload folgt in Phase H.
|
||||
<div className="dokumente-intro">
|
||||
<h2 className="dokumente-heading" data-i18n="akten.detail.dokumente.heading">Dokumente</h2>
|
||||
<p className="dokumente-subtitle" data-i18n="akten.detail.dokumente.subtitle">
|
||||
Gerichtsdokumente hochladen und per KI automatisch Fristen extrahieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="dokument-upload-wrap">
|
||||
<div id="dokument-upload-disabled" className="dokumente-disabled-notice" style="display:none" data-i18n="akten.detail.dokumente.upload.disabled">
|
||||
Dokumenten-Upload ist auf diesem Server nicht konfiguriert.
|
||||
</div>
|
||||
<label id="dokument-upload-zone" className="dokumente-upload-zone" htmlFor="dokument-file-input">
|
||||
<svg className="dokumente-upload-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="18" x2="12" y2="12" />
|
||||
<polyline points="9 15 12 12 15 15" />
|
||||
</svg>
|
||||
<div className="dokumente-upload-text" data-i18n="akten.detail.dokumente.upload.zone">
|
||||
PDF hierher ziehen oder klicken zum Auswählen
|
||||
</div>
|
||||
<div className="dokumente-upload-hint" data-i18n="akten.detail.dokumente.upload.hint">Nur PDF, max. 20 MB</div>
|
||||
<input type="file" id="dokument-file-input" accept="application/pdf" style="display:none" />
|
||||
</label>
|
||||
<div id="dokument-upload-progress" className="dokumente-upload-progress" style="display:none">
|
||||
<div className="dokumente-upload-bar"><div className="dokumente-upload-bar-fill" id="dokument-upload-bar-fill" /></div>
|
||||
<span id="dokument-upload-status" data-i18n="akten.detail.dokumente.upload.progress">Hochladen…</span>
|
||||
</div>
|
||||
<div id="dokument-upload-msg" className="form-msg" />
|
||||
</div>
|
||||
|
||||
<div className="akten-table-wrap" id="dokumente-tablewrap" style="margin-top:1.5rem">
|
||||
<table className="akten-table dokumente-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="akten.detail.dokumente.col.name">Dateiname</th>
|
||||
<th data-i18n="akten.detail.dokumente.col.uploaded">Hochgeladen</th>
|
||||
<th data-i18n="akten.detail.dokumente.col.size">Größe</th>
|
||||
<th data-i18n="akten.detail.dokumente.col.actions">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dokumente-body" />
|
||||
</table>
|
||||
</div>
|
||||
<p className="akten-events-empty" id="dokumente-empty" style="display:none" data-i18n="akten.detail.dokumente.list.empty">
|
||||
Noch keine Dokumente hochgeladen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Phase H — Extraction review modal */}
|
||||
<div className="modal-overlay" id="extraction-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 data-i18n="akten.detail.dokumente.extraction.title">Extrahierte Fristen</h2>
|
||||
<p className="modal-subtitle" data-i18n="akten.detail.dokumente.extraction.subtitle">
|
||||
Wählen Sie aus, welche Vorschläge als Fristen an die Akte übernommen werden sollen.
|
||||
</p>
|
||||
</div>
|
||||
<button className="modal-close" id="extraction-modal-close" type="button">×</button>
|
||||
</div>
|
||||
<div className="extraction-body">
|
||||
<p className="extraction-none" id="extraction-none" style="display:none" data-i18n="akten.detail.dokumente.extraction.none">
|
||||
Die KI hat keine Fristen im Dokument gefunden.
|
||||
</p>
|
||||
<table className="akten-table extraction-table" id="extraction-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.keep">Übernehmen</th>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.title">Titel</th>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.due">Fällig</th>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.rule">Regel</th>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.confidence">Konfidenz</th>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.source">Quelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="extraction-body" />
|
||||
</table>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="extraction-cancel" data-i18n="akten.detail.dokumente.extraction.cancel">Abbrechen</button>
|
||||
<button type="button" className="btn-primary btn-cta-lime" id="extraction-save" data-i18n="akten.detail.dokumente.extraction.save">Als Fristen speichern</button>
|
||||
</div>
|
||||
<p className="form-msg" id="extraction-msg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notizen — Phase I placeholder */}
|
||||
<section className="akten-tab-panel" id="tab-notizen" style="display:none">
|
||||
<div className="akten-soon">
|
||||
|
||||
@@ -46,6 +46,31 @@ interface Me {
|
||||
office: string;
|
||||
}
|
||||
|
||||
interface Dokument {
|
||||
id: string;
|
||||
akte_id: string;
|
||||
title: string;
|
||||
file_size?: number;
|
||||
mime_type?: string;
|
||||
ai_extraction_count: number;
|
||||
ai_extracted_at?: string;
|
||||
uploaded_by?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ExtractedDeadline {
|
||||
title: string;
|
||||
due_date: string;
|
||||
rule_code: string;
|
||||
confidence: number;
|
||||
source_quote: string;
|
||||
}
|
||||
|
||||
interface FeatureFlags {
|
||||
document_upload: boolean;
|
||||
ai_extraction: boolean;
|
||||
}
|
||||
|
||||
type TabId = "verlauf" | "parteien" | "fristen" | "termine" | "dokumente" | "notizen";
|
||||
|
||||
const VALID_TABS: TabId[] = ["verlauf", "parteien", "fristen", "termine", "dokumente", "notizen"];
|
||||
@@ -55,6 +80,10 @@ let me: Me | null = null;
|
||||
let parteien: Partei[] = [];
|
||||
let events: AkteEvent[] = [];
|
||||
let fristen: Frist[] = [];
|
||||
let dokumente: Dokument[] = [];
|
||||
let features: FeatureFlags = { document_upload: false, ai_extraction: false };
|
||||
let extractionDoc: Dokument | null = null;
|
||||
let extractionItems: ExtractedDeadline[] = [];
|
||||
|
||||
function parseAkteID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -116,6 +145,407 @@ async function loadFristen(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDokumente(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${id}/dokumente`);
|
||||
if (resp.ok) dokumente = await resp.json();
|
||||
} catch {
|
||||
dokumente = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFeatures() {
|
||||
try {
|
||||
const resp = await fetch("/api/config/features");
|
||||
if (resp.ok) features = await resp.json();
|
||||
} catch {
|
||||
/* optional — default flags stay false */
|
||||
}
|
||||
}
|
||||
|
||||
function fmtFileSize(bytes: number | undefined): string {
|
||||
if (!bytes || bytes <= 0) return "\u2014";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function confidenceClass(c: number): { cls: string; label: string } {
|
||||
if (c >= 0.75) {
|
||||
return { cls: "extraction-confidence-high", label: t("akten.detail.dokumente.extraction.confidence.high") };
|
||||
}
|
||||
if (c >= 0.5) {
|
||||
return { cls: "extraction-confidence-mid", label: t("akten.detail.dokumente.extraction.confidence.mid") };
|
||||
}
|
||||
return { cls: "extraction-confidence-low", label: t("akten.detail.dokumente.extraction.confidence.low") };
|
||||
}
|
||||
|
||||
function renderDokumente() {
|
||||
const body = document.getElementById("dokumente-body") as HTMLTableSectionElement | null;
|
||||
const wrap = document.getElementById("dokumente-tablewrap") as HTMLElement | null;
|
||||
const empty = document.getElementById("dokumente-empty") as HTMLElement | null;
|
||||
const uploadWrap = document.getElementById("dokument-upload-wrap") as HTMLElement | null;
|
||||
const uploadZone = document.getElementById("dokument-upload-zone") as HTMLLabelElement | null;
|
||||
const uploadDisabled = document.getElementById("dokument-upload-disabled") as HTMLElement | null;
|
||||
if (!body || !wrap || !empty || !uploadWrap || !uploadZone || !uploadDisabled) return;
|
||||
|
||||
if (features.document_upload) {
|
||||
uploadZone.style.display = "";
|
||||
uploadDisabled.style.display = "none";
|
||||
} else {
|
||||
uploadZone.style.display = "none";
|
||||
uploadDisabled.style.display = "";
|
||||
}
|
||||
|
||||
if (dokumente.length === 0) {
|
||||
body.innerHTML = "";
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
|
||||
body.innerHTML = dokumente
|
||||
.map((d) => {
|
||||
const extractedBadge =
|
||||
d.ai_extraction_count > 0
|
||||
? `<span class="dokument-extraction-badge">${d.ai_extraction_count}\u00d7</span>`
|
||||
: "";
|
||||
const extractBtn = features.ai_extraction
|
||||
? `<button type="button" class="dokumente-action-btn dokumente-action-extract" data-action="extract" data-id="${esc(d.id)}">${esc(
|
||||
t("akten.detail.dokumente.action.extract"),
|
||||
)}</button>`
|
||||
: "";
|
||||
return `<tr data-id="${esc(d.id)}">
|
||||
<td>
|
||||
<div class="dokument-name-cell">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span>${esc(d.title)}</span>${extractedBadge}
|
||||
</div>
|
||||
</td>
|
||||
<td>${fmtDateTime(d.created_at)}</td>
|
||||
<td>${fmtFileSize(d.file_size)}</td>
|
||||
<td class="dokumente-col-actions">
|
||||
<a class="dokumente-action-btn" href="/api/dokumente/${esc(d.id)}/download" target="_blank" rel="noopener">${esc(
|
||||
t("akten.detail.dokumente.action.download"),
|
||||
)}</a>
|
||||
${extractBtn}
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>('button[data-action="extract"]').forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const id = btn.dataset.id!;
|
||||
const doc = dokumente.find((d) => d.id === id) || null;
|
||||
if (!doc) return;
|
||||
await runExtraction(btn, doc);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runExtraction(btn: HTMLButtonElement, doc: Dokument) {
|
||||
const msgEl = document.getElementById("dokument-upload-msg") as HTMLElement | null;
|
||||
btn.disabled = true;
|
||||
const originalText = btn.textContent || "";
|
||||
btn.textContent = t("akten.detail.dokumente.extract.running");
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "";
|
||||
msgEl.className = "form-msg";
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/dokumente/${doc.id}/extract-deadlines`, { method: "POST" });
|
||||
if (resp.status === 429) {
|
||||
if (msgEl) {
|
||||
msgEl.textContent = t("akten.detail.dokumente.extract.ratelimit");
|
||||
msgEl.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (resp.status === 501) {
|
||||
if (msgEl) {
|
||||
msgEl.textContent = t("akten.detail.dokumente.extract.disabled");
|
||||
msgEl.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
if (msgEl) {
|
||||
msgEl.textContent = t("akten.detail.dokumente.extract.failed");
|
||||
msgEl.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as { deadlines: ExtractedDeadline[] };
|
||||
extractionDoc = doc;
|
||||
extractionItems = data.deadlines || [];
|
||||
openExtractionModal();
|
||||
// Refresh doc list so the extraction badge updates.
|
||||
if (akte) {
|
||||
await loadDokumente(akte.id);
|
||||
renderDokumente();
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
function openExtractionModal() {
|
||||
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
|
||||
const body = document.getElementById("extraction-body") as HTMLTableSectionElement | null;
|
||||
const none = document.getElementById("extraction-none") as HTMLElement | null;
|
||||
const table = document.getElementById("extraction-table") as HTMLElement | null;
|
||||
const msg = document.getElementById("extraction-msg") as HTMLElement | null;
|
||||
if (!modal || !body || !none || !table) return;
|
||||
if (msg) {
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
}
|
||||
|
||||
if (extractionItems.length === 0) {
|
||||
table.style.display = "none";
|
||||
none.style.display = "block";
|
||||
} else {
|
||||
table.style.display = "";
|
||||
none.style.display = "none";
|
||||
body.innerHTML = extractionItems
|
||||
.map((it, idx) => {
|
||||
const confCls = confidenceClass(it.confidence).cls;
|
||||
const confLabel = confidenceClass(it.confidence).label;
|
||||
const confPct = Math.round(it.confidence * 100);
|
||||
const missingDate = !it.due_date
|
||||
? `<div class="extraction-missing-date">${esc(t("akten.detail.dokumente.extraction.missing.date"))}</div>`
|
||||
: "";
|
||||
const quoteId = `extraction-quote-${idx}`;
|
||||
return `<tr data-idx="${idx}">
|
||||
<td><input type="checkbox" class="extraction-keep" ${it.due_date ? "checked" : ""} /></td>
|
||||
<td><input type="text" class="extraction-title-input" value="${esc(it.title)}" /></td>
|
||||
<td>
|
||||
<input type="date" class="extraction-due-input" value="${esc(it.due_date)}" />
|
||||
${missingDate}
|
||||
</td>
|
||||
<td><input type="text" class="extraction-rule-input" value="${esc(it.rule_code)}" placeholder="z.B. Rule 23 RoP" /></td>
|
||||
<td><span class="extraction-confidence ${confCls}">${confPct}% ${esc(confLabel)}</span></td>
|
||||
<td>
|
||||
<button type="button" class="extraction-source-toggle" data-target="${quoteId}">${esc(
|
||||
t("akten.detail.dokumente.extraction.source.show"),
|
||||
)}</button>
|
||||
<div class="extraction-source-quote" id="${quoteId}">\u201E${esc(it.source_quote)}\u201C</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".extraction-source-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tgt = document.getElementById(btn.dataset.target!) as HTMLElement | null;
|
||||
if (!tgt) return;
|
||||
const open = tgt.classList.toggle("extraction-source-quote-open");
|
||||
btn.textContent = open
|
||||
? t("akten.detail.dokumente.extraction.source.hide")
|
||||
: t("akten.detail.dokumente.extraction.source.show");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeExtractionModal() {
|
||||
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
|
||||
if (modal) modal.style.display = "none";
|
||||
extractionDoc = null;
|
||||
extractionItems = [];
|
||||
}
|
||||
|
||||
async function saveExtractedFristen() {
|
||||
if (!akte || !extractionDoc) return;
|
||||
const msg = document.getElementById("extraction-msg") as HTMLElement | null;
|
||||
const saveBtn = document.getElementById("extraction-save") as HTMLButtonElement | null;
|
||||
if (msg) {
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
}
|
||||
|
||||
const rows = Array.from(document.querySelectorAll<HTMLTableRowElement>("#extraction-body tr"));
|
||||
const items: Array<{ title: string; due_date: string; rule_code?: string; source_quote?: string }> = [];
|
||||
for (const row of rows) {
|
||||
const cb = row.querySelector<HTMLInputElement>(".extraction-keep");
|
||||
if (!cb?.checked) continue;
|
||||
const titleInput = row.querySelector<HTMLInputElement>(".extraction-title-input");
|
||||
const dueInput = row.querySelector<HTMLInputElement>(".extraction-due-input");
|
||||
const ruleInput = row.querySelector<HTMLInputElement>(".extraction-rule-input");
|
||||
if (!titleInput || !dueInput) continue;
|
||||
const title = titleInput.value.trim();
|
||||
const due = dueInput.value.trim();
|
||||
const rule = ruleInput?.value.trim() || "";
|
||||
if (!title || !due) continue;
|
||||
const idx = parseInt(row.dataset.idx || "-1", 10);
|
||||
const sourceQuote = idx >= 0 ? extractionItems[idx]?.source_quote : "";
|
||||
items.push({
|
||||
title,
|
||||
due_date: due,
|
||||
rule_code: rule || undefined,
|
||||
source_quote: sourceQuote || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
if (msg) {
|
||||
msg.textContent = t("akten.detail.dokumente.extraction.save.none");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${akte.id}/fristen/from-extraction`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ document_id: extractionDoc.id, items }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (msg) {
|
||||
msg.textContent = t("akten.detail.dokumente.extraction.save.failed");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
const count = Array.isArray(created) ? created.length : items.length;
|
||||
if (msg) {
|
||||
const template = t("akten.detail.dokumente.extraction.save.success");
|
||||
msg.textContent = template.replace("{n}", String(count));
|
||||
msg.className = "form-msg form-msg-success";
|
||||
}
|
||||
if (akte) {
|
||||
await Promise.all([loadFristen(akte.id), loadEvents(akte.id), loadDokumente(akte.id)]);
|
||||
renderFristen();
|
||||
renderEvents();
|
||||
renderDokumente();
|
||||
}
|
||||
// Auto-close after short delay so user can see the success message.
|
||||
setTimeout(() => closeExtractionModal(), 1200);
|
||||
} finally {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function initDokumenteUpload() {
|
||||
const input = document.getElementById("dokument-file-input") as HTMLInputElement | null;
|
||||
const zone = document.getElementById("dokument-upload-zone") as HTMLLabelElement | null;
|
||||
const progress = document.getElementById("dokument-upload-progress") as HTMLElement | null;
|
||||
const bar = document.getElementById("dokument-upload-bar-fill") as HTMLElement | null;
|
||||
const status = document.getElementById("dokument-upload-status") as HTMLElement | null;
|
||||
const msg = document.getElementById("dokument-upload-msg") as HTMLElement | null;
|
||||
if (!input || !zone || !progress || !bar || !status || !msg) return;
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0 || !akte) return;
|
||||
const file = files[0]!;
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
|
||||
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
||||
msg.textContent = t("akten.detail.dokumente.upload.notpdf");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
msg.textContent = t("akten.detail.dokumente.upload.toolarge");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
progress.style.display = "flex";
|
||||
bar.style.width = "0%";
|
||||
status.textContent = t("akten.detail.dokumente.upload.progress");
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
// Use XHR for real upload-progress reporting (fetch can't stream upload
|
||||
// progress in browsers without the experimental ReadableStream.pipeTo).
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `/api/akten/${akte.id}/dokumente`);
|
||||
xhr.upload.addEventListener("progress", (ev) => {
|
||||
if (ev.lengthComputable) {
|
||||
const pct = Math.round((ev.loaded / ev.total) * 100);
|
||||
bar.style.width = `${pct}%`;
|
||||
}
|
||||
});
|
||||
xhr.onload = async () => {
|
||||
progress.style.display = "none";
|
||||
input.value = "";
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
msg.textContent = t("akten.detail.dokumente.upload.success");
|
||||
msg.className = "form-msg form-msg-success";
|
||||
if (akte) {
|
||||
await loadDokumente(akte.id);
|
||||
renderDokumente();
|
||||
await loadEvents(akte.id);
|
||||
renderEvents();
|
||||
}
|
||||
} else if (xhr.status === 413) {
|
||||
msg.textContent = t("akten.detail.dokumente.upload.toolarge");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} else {
|
||||
let reason = t("akten.detail.dokumente.upload.failed");
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText) as { error?: string };
|
||||
if (body.error) reason = body.error;
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
msg.textContent = reason;
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
progress.style.display = "none";
|
||||
msg.textContent = t("akten.detail.dokumente.upload.failed");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
};
|
||||
xhr.send(fd);
|
||||
};
|
||||
|
||||
input.addEventListener("change", () => handleFiles(input.files));
|
||||
|
||||
// Drag-drop on the zone.
|
||||
zone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.add("dokumente-upload-zone-drag");
|
||||
});
|
||||
zone.addEventListener("dragleave", () => {
|
||||
zone.classList.remove("dokumente-upload-zone-drag");
|
||||
});
|
||||
zone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove("dokumente-upload-zone-drag");
|
||||
handleFiles(e.dataTransfer?.files ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
function initExtractionModal() {
|
||||
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
|
||||
const closeBtn = document.getElementById("extraction-modal-close") as HTMLElement | null;
|
||||
const cancelBtn = document.getElementById("extraction-cancel") as HTMLElement | null;
|
||||
const saveBtn = document.getElementById("extraction-save") as HTMLElement | null;
|
||||
if (!modal || !closeBtn || !cancelBtn || !saveBtn) return;
|
||||
closeBtn.addEventListener("click", closeExtractionModal);
|
||||
cancelBtn.addEventListener("click", closeExtractionModal);
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeExtractionModal();
|
||||
});
|
||||
saveBtn.addEventListener("click", saveExtractedFristen);
|
||||
}
|
||||
|
||||
function fmtDateOnly(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso.slice(0, 10) + "T00:00:00");
|
||||
@@ -500,7 +930,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadMe();
|
||||
await Promise.all([loadMe(), loadFeatures()]);
|
||||
const ok = await loadAkte(id);
|
||||
if (!ok || !akte) {
|
||||
loading.style.display = "none";
|
||||
@@ -508,7 +938,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadParteien(id), loadEvents(id), loadFristen(id)]);
|
||||
await Promise.all([loadParteien(id), loadEvents(id), loadFristen(id), loadDokumente(id)]);
|
||||
|
||||
loading.style.display = "none";
|
||||
body.style.display = "";
|
||||
@@ -516,11 +946,14 @@ async function main() {
|
||||
renderParteien();
|
||||
renderEvents();
|
||||
renderFristen();
|
||||
renderDokumente();
|
||||
initFristAddLink();
|
||||
initTabs();
|
||||
initTitleEdit();
|
||||
initParteienForm();
|
||||
initDelete();
|
||||
initDokumenteUpload();
|
||||
initExtractionModal();
|
||||
showTab(parseTab());
|
||||
}
|
||||
|
||||
@@ -532,6 +965,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
renderEvents();
|
||||
renderParteien();
|
||||
renderFristen();
|
||||
renderDokumente();
|
||||
});
|
||||
main();
|
||||
});
|
||||
|
||||
@@ -428,8 +428,51 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"akten.detail.soon": "Bald verf\u00fcgbar",
|
||||
"akten.detail.soon.fristen": "Fristenverwaltung kommt in Phase E \u2014 diese Akte wird dann Fristen anzeigen.",
|
||||
"akten.detail.soon.termine": "Termine & CalDAV-Sync folgen in Phase F.",
|
||||
"akten.detail.soon.dokumente": "Dokumenten-Upload folgt in Phase H.",
|
||||
"akten.detail.soon.notizen": "Notizfunktion folgt in Phase I.",
|
||||
|
||||
// Phase H — Dokumente + KI-Extraktion
|
||||
"akten.detail.dokumente.heading": "Dokumente",
|
||||
"akten.detail.dokumente.subtitle": "Gerichtsdokumente hochladen und per KI automatisch Fristen extrahieren.",
|
||||
"akten.detail.dokumente.upload.zone": "PDF hierher ziehen oder klicken zum Ausw\u00e4hlen",
|
||||
"akten.detail.dokumente.upload.hint": "Nur PDF, max. 20 MB",
|
||||
"akten.detail.dokumente.upload.disabled": "Dokumenten-Upload ist auf diesem Server nicht konfiguriert.",
|
||||
"akten.detail.dokumente.upload.progress": "Hochladen\u2026",
|
||||
"akten.detail.dokumente.upload.success": "Dokument hochgeladen.",
|
||||
"akten.detail.dokumente.upload.failed": "Upload fehlgeschlagen.",
|
||||
"akten.detail.dokumente.upload.notpdf": "Nur PDF-Dateien werden akzeptiert.",
|
||||
"akten.detail.dokumente.upload.toolarge": "Datei ist zu gro\u00df (max. 20 MB).",
|
||||
"akten.detail.dokumente.list.empty": "Noch keine Dokumente hochgeladen.",
|
||||
"akten.detail.dokumente.col.name": "Dateiname",
|
||||
"akten.detail.dokumente.col.uploaded": "Hochgeladen",
|
||||
"akten.detail.dokumente.col.size": "Gr\u00f6\u00dfe",
|
||||
"akten.detail.dokumente.col.actions": "Aktionen",
|
||||
"akten.detail.dokumente.action.download": "Herunterladen",
|
||||
"akten.detail.dokumente.action.extract": "Fristen extrahieren",
|
||||
"akten.detail.dokumente.extract.running": "KI extrahiert\u2026",
|
||||
"akten.detail.dokumente.extract.failed": "Extraktion fehlgeschlagen.",
|
||||
"akten.detail.dokumente.extract.ratelimit": "Tageslimit f\u00fcr KI-Extraktionen erreicht (20 pro Tag).",
|
||||
"akten.detail.dokumente.extract.disabled": "KI-Extraktion ist auf diesem Server nicht konfiguriert.",
|
||||
"akten.detail.dokumente.extraction.title": "Extrahierte Fristen",
|
||||
"akten.detail.dokumente.extraction.subtitle": "W\u00e4hlen Sie aus, welche Vorschl\u00e4ge als Fristen an die Akte \u00fcbernommen werden sollen.",
|
||||
"akten.detail.dokumente.extraction.none": "Die KI hat keine Fristen im Dokument gefunden.",
|
||||
"akten.detail.dokumente.extraction.col.keep": "\u00dcbernehmen",
|
||||
"akten.detail.dokumente.extraction.col.title": "Titel",
|
||||
"akten.detail.dokumente.extraction.col.due": "F\u00e4llig",
|
||||
"akten.detail.dokumente.extraction.col.rule": "Regel",
|
||||
"akten.detail.dokumente.extraction.col.confidence": "Konfidenz",
|
||||
"akten.detail.dokumente.extraction.col.source": "Quelle",
|
||||
"akten.detail.dokumente.extraction.source.show": "Zitat anzeigen",
|
||||
"akten.detail.dokumente.extraction.source.hide": "Zitat verbergen",
|
||||
"akten.detail.dokumente.extraction.cancel": "Abbrechen",
|
||||
"akten.detail.dokumente.extraction.save": "Als Fristen speichern",
|
||||
"akten.detail.dokumente.extraction.save.none": "Mindestens eine Frist mit F\u00e4lligkeitsdatum ausw\u00e4hlen.",
|
||||
"akten.detail.dokumente.extraction.save.success": "{n} Fristen \u00fcbernommen.",
|
||||
"akten.detail.dokumente.extraction.save.failed": "Speichern fehlgeschlagen.",
|
||||
"akten.detail.dokumente.extraction.view.fristen": "Zu den Fristen \u2192",
|
||||
"akten.detail.dokumente.extraction.confidence.low": "niedrig",
|
||||
"akten.detail.dokumente.extraction.confidence.mid": "mittel",
|
||||
"akten.detail.dokumente.extraction.confidence.high": "hoch",
|
||||
"akten.detail.dokumente.extraction.missing.date": "Kein Datum",
|
||||
"akten.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
|
||||
"akten.detail.parteien.add": "Partei hinzuf\u00fcgen",
|
||||
"akten.detail.parteien.empty": "Noch keine Parteien eingetragen.",
|
||||
@@ -1041,8 +1084,51 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"akten.detail.soon": "Coming soon",
|
||||
"akten.detail.soon.fristen": "Deadline management ships in Phase E \u2014 this matter will then list its deadlines here.",
|
||||
"akten.detail.soon.termine": "Appointments & CalDAV sync follow in Phase F.",
|
||||
"akten.detail.soon.dokumente": "Document upload lands in Phase H.",
|
||||
"akten.detail.soon.notizen": "Notes ship in Phase I.",
|
||||
|
||||
// Phase H — Documents + AI extraction
|
||||
"akten.detail.dokumente.heading": "Documents",
|
||||
"akten.detail.dokumente.subtitle": "Upload court documents and extract deadlines automatically with AI.",
|
||||
"akten.detail.dokumente.upload.zone": "Drop a PDF here or click to choose",
|
||||
"akten.detail.dokumente.upload.hint": "PDF only, max 20 MB",
|
||||
"akten.detail.dokumente.upload.disabled": "Document upload is not configured on this server.",
|
||||
"akten.detail.dokumente.upload.progress": "Uploading\u2026",
|
||||
"akten.detail.dokumente.upload.success": "Document uploaded.",
|
||||
"akten.detail.dokumente.upload.failed": "Upload failed.",
|
||||
"akten.detail.dokumente.upload.notpdf": "Only PDF files are accepted.",
|
||||
"akten.detail.dokumente.upload.toolarge": "File is too large (max 20 MB).",
|
||||
"akten.detail.dokumente.list.empty": "No documents uploaded yet.",
|
||||
"akten.detail.dokumente.col.name": "Filename",
|
||||
"akten.detail.dokumente.col.uploaded": "Uploaded",
|
||||
"akten.detail.dokumente.col.size": "Size",
|
||||
"akten.detail.dokumente.col.actions": "Actions",
|
||||
"akten.detail.dokumente.action.download": "Download",
|
||||
"akten.detail.dokumente.action.extract": "Extract deadlines",
|
||||
"akten.detail.dokumente.extract.running": "AI extracting\u2026",
|
||||
"akten.detail.dokumente.extract.failed": "Extraction failed.",
|
||||
"akten.detail.dokumente.extract.ratelimit": "Daily limit for AI extractions reached (20 per day).",
|
||||
"akten.detail.dokumente.extract.disabled": "AI extraction is not configured on this server.",
|
||||
"akten.detail.dokumente.extraction.title": "Extracted deadlines",
|
||||
"akten.detail.dokumente.extraction.subtitle": "Choose which suggestions to save as deadlines on this matter.",
|
||||
"akten.detail.dokumente.extraction.none": "The AI did not find any deadlines in this document.",
|
||||
"akten.detail.dokumente.extraction.col.keep": "Keep",
|
||||
"akten.detail.dokumente.extraction.col.title": "Title",
|
||||
"akten.detail.dokumente.extraction.col.due": "Due",
|
||||
"akten.detail.dokumente.extraction.col.rule": "Rule",
|
||||
"akten.detail.dokumente.extraction.col.confidence": "Confidence",
|
||||
"akten.detail.dokumente.extraction.col.source": "Source",
|
||||
"akten.detail.dokumente.extraction.source.show": "Show quote",
|
||||
"akten.detail.dokumente.extraction.source.hide": "Hide quote",
|
||||
"akten.detail.dokumente.extraction.cancel": "Cancel",
|
||||
"akten.detail.dokumente.extraction.save": "Save as deadlines",
|
||||
"akten.detail.dokumente.extraction.save.none": "Select at least one deadline with a due date.",
|
||||
"akten.detail.dokumente.extraction.save.success": "{n} deadlines saved.",
|
||||
"akten.detail.dokumente.extraction.save.failed": "Save failed.",
|
||||
"akten.detail.dokumente.extraction.view.fristen": "View deadlines \u2192",
|
||||
"akten.detail.dokumente.extraction.confidence.low": "low",
|
||||
"akten.detail.dokumente.extraction.confidence.mid": "medium",
|
||||
"akten.detail.dokumente.extraction.confidence.high": "high",
|
||||
"akten.detail.dokumente.extraction.missing.date": "No date",
|
||||
"akten.detail.verlauf.empty": "No events recorded yet.",
|
||||
"akten.detail.parteien.add": "Add party",
|
||||
"akten.detail.parteien.empty": "No parties recorded yet.",
|
||||
|
||||
@@ -4930,3 +4930,267 @@ input[type="range"]::-moz-range-thumb {
|
||||
gap: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Phase H — Documents tab + AI extraction
|
||||
============================================================================ */
|
||||
|
||||
.dokumente-intro {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.dokumente-heading {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.dokumente-subtitle {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.92rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dokumente-disabled-notice {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||
color: #7a5a00;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dokumente-upload-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 2rem 1.5rem;
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: calc(var(--radius) * 1.25);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface-muted, #fafafa);
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dokumente-upload-zone:hover,
|
||||
.dokumente-upload-zone.dokumente-upload-zone-drag {
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
background: rgba(198, 244, 28, 0.08);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dokumente-upload-icon {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dokumente-upload-zone:hover .dokumente-upload-icon,
|
||||
.dokumente-upload-zone.dokumente-upload-zone-drag .dokumente-upload-icon {
|
||||
color: #4a6a0b;
|
||||
}
|
||||
|
||||
.dokumente-upload-text {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dokumente-upload-hint {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dokumente-upload-progress {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dokumente-upload-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--color-border);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dokumente-upload-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent, #c6f41c);
|
||||
width: 0%;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.dokumente-table .dokumente-col-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dokumente-action-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
margin-right: 0.35rem;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dokumente-action-btn:hover {
|
||||
background: var(--color-surface-muted, #f5f5f5);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dokumente-action-btn.dokumente-action-extract {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
color: #1a2a00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dokumente-action-btn.dokumente-action-extract:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.dokument-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dokument-name-cell svg {
|
||||
color: #a00;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dokument-extraction-badge {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Extraction review modal — wider than default */
|
||||
.modal-card-wide {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.modal-header .modal-subtitle {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.extraction-body {
|
||||
padding: 0.5rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.extraction-none {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.extraction-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.extraction-table td,
|
||||
.extraction-table th {
|
||||
vertical-align: top;
|
||||
padding: 0.6rem 0.5rem;
|
||||
}
|
||||
|
||||
.extraction-table tbody tr:hover {
|
||||
background: var(--color-surface-muted, #fafafa);
|
||||
}
|
||||
|
||||
.extraction-title-input,
|
||||
.extraction-due-input,
|
||||
.extraction-rule-input {
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.88rem;
|
||||
font-family: inherit;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.extraction-due-input {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.extraction-confidence {
|
||||
font-size: 0.82rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.extraction-confidence-high {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
.extraction-confidence-mid {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #7a4a00;
|
||||
}
|
||||
|
||||
.extraction-confidence-low {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #7a0d0d;
|
||||
}
|
||||
|
||||
.extraction-source-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-link, #3b5bdb);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.extraction-source-quote {
|
||||
display: none;
|
||||
font-size: 0.82rem;
|
||||
background: var(--color-surface-muted, #fafafa);
|
||||
border-left: 3px solid var(--color-accent, #c6f41c);
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.extraction-source-quote.extraction-source-quote-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.extraction-missing-date {
|
||||
font-size: 0.75rem;
|
||||
color: #b00;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.modal-card-wide {
|
||||
max-width: 95vw;
|
||||
}
|
||||
.extraction-table {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.extraction-due-input {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
17
go.mod
17
go.mod
@@ -3,8 +3,17 @@ module mgit.msbls.de/m/patholo
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jmoiron/sqlx v1.4.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/anthropics/anthropic-sdk-go v1.37.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lib/pq v1.12.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
)
|
||||
|
||||
80
go.sum
80
go.sum
@@ -1,13 +1,91 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/anthropics/anthropic-sdk-go v1.37.0 h1:yBKUaBG3TCRb6das/Q5qNB9Fsafon09gu2yYVgvapKE=
|
||||
github.com/anthropics/anthropic-sdk-go v1.37.0/go.mod h1:dSIO7kSrOI7MA4fE6RRVaw8tyWP7HNQU5/H/KS4cax8=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
8
internal/db/migrations/013_dokumente_extraction.down.sql
Normal file
8
internal/db/migrations/013_dokumente_extraction.down.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
DROP INDEX IF EXISTS paliad.fristen_source_document_idx;
|
||||
|
||||
ALTER TABLE paliad.fristen
|
||||
DROP COLUMN IF EXISTS source_document_id;
|
||||
|
||||
ALTER TABLE paliad.dokumente
|
||||
DROP COLUMN IF EXISTS ai_extraction_count,
|
||||
DROP COLUMN IF EXISTS ai_extracted_at;
|
||||
24
internal/db/migrations/013_dokumente_extraction.up.sql
Normal file
24
internal/db/migrations/013_dokumente_extraction.up.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Phase H: wire documents to AI deadline extraction.
|
||||
--
|
||||
-- paliad.dokumente already exists (migration 005). This migration:
|
||||
-- 1. Adds source_document_id to paliad.fristen so Fristen extracted from a
|
||||
-- document remember where they came from (for audit + "extracted from X"
|
||||
-- badges in the UI).
|
||||
-- 2. Adds ai_extraction_count + ai_extracted_at to paliad.dokumente so the
|
||||
-- UI can show "extracted N times" and link to the last extraction result
|
||||
-- stored in ai_extracted (jsonb, already present).
|
||||
--
|
||||
-- Rate limiting (20 extractions/day/user) is enforced in-memory in the
|
||||
-- application layer; persisting it would need a new table but the spec says
|
||||
-- in-memory is acceptable for the first release.
|
||||
|
||||
ALTER TABLE paliad.fristen
|
||||
ADD COLUMN source_document_id uuid REFERENCES paliad.dokumente(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX fristen_source_document_idx
|
||||
ON paliad.fristen (source_document_id)
|
||||
WHERE source_document_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE paliad.dokumente
|
||||
ADD COLUMN ai_extraction_count integer NOT NULL DEFAULT 0,
|
||||
ADD COLUMN ai_extracted_at timestamptz;
|
||||
@@ -22,6 +22,7 @@ type dbServices struct {
|
||||
users *services.UserService
|
||||
fristenrechner *services.FristenrechnerService
|
||||
dashboard *services.DashboardService
|
||||
dokument *services.DokumentService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
279
internal/handlers/dokumente.go
Normal file
279
internal/handlers/dokumente.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/config/features — public feature flags for the logged-in UI.
|
||||
// Tells the frontend which Phase H capabilities are available so it can
|
||||
// hide upload / extract buttons rather than show broken controls.
|
||||
func handleFeatures(w http.ResponseWriter, r *http.Request) {
|
||||
upload := false
|
||||
extract := false
|
||||
if dbSvc != nil && dbSvc.dokument != nil {
|
||||
upload = dbSvc.dokument.UploadEnabled()
|
||||
extract = dbSvc.dokument.ExtractionEnabled()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{
|
||||
"document_upload": upload,
|
||||
"ai_extraction": extract,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/akten/{id}/dokumente — multipart PDF upload.
|
||||
func handleUploadDokument(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dokument == nil || !dbSvc.dokument.UploadEnabled() {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{
|
||||
"error": "Dokumenten-Upload ist nicht konfiguriert (SUPABASE_SERVICE_KEY fehlt).",
|
||||
})
|
||||
return
|
||||
}
|
||||
akteID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Cap the total request size slightly above the 20 MB blob limit so we
|
||||
// refuse oversized uploads before buffering the full body.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, services.MaxUploadBytes+4096)
|
||||
if err := r.ParseMultipartForm(services.MaxUploadBytes + 4096); err != nil {
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{
|
||||
"error": "Upload zu groß (Maximum 20 MB).",
|
||||
})
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "Feld 'file' fehlt im Upload.",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
filename := sanitizeFilename(header.Filename)
|
||||
if filename == "" {
|
||||
filename = "document.pdf"
|
||||
}
|
||||
|
||||
doc, err := dbSvc.dokument.UploadPDF(r.Context(), uid, akteID, filename, file)
|
||||
if err != nil {
|
||||
writeDokumentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, doc)
|
||||
}
|
||||
|
||||
// GET /api/akten/{id}/dokumente
|
||||
func handleListDokumente(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dokument == nil {
|
||||
writeJSON(w, http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
akteID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.dokument.ListForAkte(r.Context(), uid, akteID)
|
||||
if err != nil {
|
||||
writeDokumentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /api/dokumente/{id}/download — streams the stored PDF bytes.
|
||||
func handleDownloadDokument(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dokument == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{
|
||||
"error": "Dokument-Download ist nicht konfiguriert.",
|
||||
})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
body, contentType, filename, err := dbSvc.dokument.Download(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeDokumentError(w, err)
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
if contentType == "" {
|
||||
contentType = "application/pdf"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`inline; filename="%s"`, escapeHeaderValue(filename)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, body)
|
||||
}
|
||||
|
||||
// POST /api/dokumente/{id}/extract-deadlines — kicks off AI extraction.
|
||||
func handleExtractDeadlines(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dokument == nil || !dbSvc.dokument.ExtractionEnabled() {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{
|
||||
"error": "KI-Extraktion ist nicht konfiguriert (ANTHROPIC_API_KEY fehlt).",
|
||||
})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Per-user rate limit (20/day) — fail fast before incurring API cost.
|
||||
if ok, _ := extractionLimiter.Check(uid); !ok {
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{
|
||||
"error": "Tageslimit für KI-Extraktionen erreicht (20 pro Tag). Bitte morgen erneut versuchen.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
items, err := dbSvc.dokument.ExtractDeadlines(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeDokumentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"document_id": id,
|
||||
"deadlines": items,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/akten/{id}/fristen/from-extraction — persists confirmed items.
|
||||
func handlePersistExtractedFristen(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dokument == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{
|
||||
"error": "Dokument-Service ist nicht konfiguriert.",
|
||||
})
|
||||
return
|
||||
}
|
||||
akteID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
Items []services.PersistFristenInput `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
docID, err := uuid.Parse(body.DocumentID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid document_id"})
|
||||
return
|
||||
}
|
||||
|
||||
created, err := dbSvc.dokument.PersistExtractedFristen(r.Context(), uid, akteID, docID, body.Items)
|
||||
if err != nil {
|
||||
writeDokumentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// writeDokumentError translates service errors into HTTP responses, folding
|
||||
// in the Phase H-specific ErrAIDisabled / ErrStorageDisabled sentinels.
|
||||
func writeDokumentError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrAIDisabled):
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{
|
||||
"error": "KI-Extraktion ist nicht konfiguriert.",
|
||||
})
|
||||
case errors.Is(err, services.ErrStorageDisabled):
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{
|
||||
"error": "Dokument-Speicher ist nicht konfiguriert.",
|
||||
})
|
||||
default:
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeFilename strips directory components and a limited set of risky
|
||||
// characters from the user-supplied filename before we store it.
|
||||
func sanitizeFilename(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// Drop any path prefix (Windows or Unix).
|
||||
if i := strings.LastIndexAny(raw, "/\\"); i >= 0 {
|
||||
raw = raw[i+1:]
|
||||
}
|
||||
// Kill control chars + quotes + semicolons to keep the Content-
|
||||
// Disposition header safe.
|
||||
var b strings.Builder
|
||||
for _, r := range raw {
|
||||
if r < 32 || r == '"' || r == ';' || r == '\\' {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
out := strings.TrimSpace(b.String())
|
||||
if len(out) > 180 {
|
||||
out = out[:180]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// escapeHeaderValue is a minimal guard so a filename like `a"b` cannot break
|
||||
// out of the Content-Disposition quoted-string.
|
||||
func escapeHeaderValue(s string) string {
|
||||
return strings.NewReplacer(`"`, ``, "\n", "", "\r", "").Replace(s)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type Services struct {
|
||||
Users *services.UserService
|
||||
Fristenrechner *services.FristenrechnerService
|
||||
Dashboard *services.DashboardService
|
||||
Dokument *services.DokumentService
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
|
||||
@@ -37,6 +38,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
users: svc.Users,
|
||||
fristenrechner: svc.Fristenrechner,
|
||||
dashboard: svc.Dashboard,
|
||||
dokument: svc.Dokument,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +114,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/akten/{id}/fristen", handleCreateFrist)
|
||||
protected.HandleFunc("POST /api/akten/{id}/fristen/bulk", handleBulkCreateFristen)
|
||||
|
||||
// Phase H — Documents + AI deadline extraction
|
||||
protected.HandleFunc("GET /api/config/features", handleFeatures)
|
||||
protected.HandleFunc("POST /api/akten/{id}/dokumente", handleUploadDokument)
|
||||
protected.HandleFunc("GET /api/akten/{id}/dokumente", handleListDokumente)
|
||||
protected.HandleFunc("GET /api/dokumente/{id}/download", handleDownloadDokument)
|
||||
protected.HandleFunc("POST /api/dokumente/{id}/extract-deadlines", handleExtractDeadlines)
|
||||
protected.HandleFunc("POST /api/akten/{id}/fristen/from-extraction", handlePersistExtractedFristen)
|
||||
|
||||
protected.HandleFunc("GET /api/me", handleGetMe)
|
||||
protected.HandleFunc("GET /api/users", handleListUsers)
|
||||
protected.HandleFunc("GET /api/dashboard", handleDashboardAPI)
|
||||
|
||||
47
internal/handlers/ratelimit.go
Normal file
47
internal/handlers/ratelimit.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// extractionRateLimit enforces a per-user daily cap on AI extraction calls.
|
||||
// In-memory, not persistent — acceptable for the first release per the
|
||||
// Phase H spec. Keys roll over at local midnight; the map is pruned on each
|
||||
// check to keep it bounded.
|
||||
type extractionRateLimit struct {
|
||||
mu sync.Mutex
|
||||
limit int
|
||||
hits map[uuid.UUID]*rateBucket
|
||||
}
|
||||
|
||||
type rateBucket struct {
|
||||
count int
|
||||
day string // YYYY-MM-DD; rotate when it changes
|
||||
}
|
||||
|
||||
var extractionLimiter = &extractionRateLimit{
|
||||
limit: 20,
|
||||
hits: make(map[uuid.UUID]*rateBucket),
|
||||
}
|
||||
|
||||
// Check increments the user's counter and returns true if under the limit.
|
||||
// Returns (false, remaining=0) once the daily cap is hit.
|
||||
func (r *extractionRateLimit) Check(userID uuid.UUID) (ok bool, remaining int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
b, exists := r.hits[userID]
|
||||
if !exists || b.day != today {
|
||||
b = &rateBucket{day: today}
|
||||
r.hits[userID] = b
|
||||
}
|
||||
if b.count >= r.limit {
|
||||
return false, 0
|
||||
}
|
||||
b.count++
|
||||
return true, r.limit - b.count
|
||||
}
|
||||
@@ -26,21 +26,21 @@ type User struct {
|
||||
|
||||
// Akte is a matter (case file). Office-scoped visibility: see paliad.can_see_akte.
|
||||
type Akte struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Aktenzeichen string `db:"aktenzeichen" json:"aktenzeichen"`
|
||||
Title string `db:"title" json:"title"`
|
||||
AkteType *string `db:"akte_type" json:"akte_type,omitempty"`
|
||||
Court *string `db:"court" json:"court,omitempty"`
|
||||
CourtRef *string `db:"court_ref" json:"court_ref,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
|
||||
OwningOffice string `db:"owning_office" json:"owning_office"`
|
||||
Collaborators pq.StringArray `db:"collaborators" json:"collaborators"`
|
||||
FirmWideVisible bool `db:"firm_wide_visible" json:"firm_wide_visible"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Aktenzeichen string `db:"aktenzeichen" json:"aktenzeichen"`
|
||||
Title string `db:"title" json:"title"`
|
||||
AkteType *string `db:"akte_type" json:"akte_type,omitempty"`
|
||||
Court *string `db:"court" json:"court,omitempty"`
|
||||
CourtRef *string `db:"court_ref" json:"court_ref,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
|
||||
OwningOffice string `db:"owning_office" json:"owning_office"`
|
||||
Collaborators pq.StringArray `db:"collaborators" json:"collaborators"`
|
||||
FirmWideVisible bool `db:"firm_wide_visible" json:"firm_wide_visible"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// AkteEvent is one row in the per-Akte audit trail.
|
||||
@@ -60,23 +60,24 @@ type AkteEvent struct {
|
||||
// Frist is one persistent deadline attached to an Akte.
|
||||
// Visibility is inherited from the parent Akte (see paliad.can_see_akte).
|
||||
type Frist struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
AkteID uuid.UUID `db:"akte_id" json:"akte_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
DueDate time.Time `db:"due_date" json:"due_date"`
|
||||
OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"`
|
||||
WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"`
|
||||
Source string `db:"source" json:"source"`
|
||||
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
|
||||
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
|
||||
Notes *string `db:"notes" json:"notes,omitempty"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
AkteID uuid.UUID `db:"akte_id" json:"akte_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
DueDate time.Time `db:"due_date" json:"due_date"`
|
||||
OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"`
|
||||
WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"`
|
||||
Source string `db:"source" json:"source"`
|
||||
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
|
||||
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
|
||||
Notes *string `db:"notes" json:"notes,omitempty"`
|
||||
SourceDocumentID *uuid.UUID `db:"source_document_id" json:"source_document_id,omitempty"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// FristWithAkte enriches a Frist with parent Akte fields needed by the
|
||||
@@ -84,12 +85,31 @@ type Frist struct {
|
||||
// per-row /api/akten/{id} fetch.
|
||||
type FristWithAkte struct {
|
||||
Frist
|
||||
AkteAktenzeichen string `db:"akte_aktenzeichen" json:"akte_aktenzeichen"`
|
||||
AkteTitle string `db:"akte_title" json:"akte_title"`
|
||||
AkteOffice string `db:"akte_office" json:"akte_office"`
|
||||
AkteAktenzeichen string `db:"akte_aktenzeichen" json:"akte_aktenzeichen"`
|
||||
AkteTitle string `db:"akte_title" json:"akte_title"`
|
||||
AkteOffice string `db:"akte_office" json:"akte_office"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
}
|
||||
|
||||
// Dokument is a file uploaded against an Akte. Blob lives in Supabase
|
||||
// Storage (bucket paliad-documents); only metadata is stored here.
|
||||
// Visibility is inherited from the parent Akte.
|
||||
type Dokument struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
AkteID uuid.UUID `db:"akte_id" json:"akte_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
DocType *string `db:"doc_type" json:"doc_type,omitempty"`
|
||||
FilePath *string `db:"file_path" json:"file_path,omitempty"`
|
||||
FileSize *int64 `db:"file_size" json:"file_size,omitempty"`
|
||||
MimeType *string `db:"mime_type" json:"mime_type,omitempty"`
|
||||
AIExtracted json.RawMessage `db:"ai_extracted" json:"ai_extracted,omitempty"`
|
||||
AIExtractionCount int `db:"ai_extraction_count" json:"ai_extraction_count"`
|
||||
AIExtractedAt *time.Time `db:"ai_extracted_at" json:"ai_extracted_at,omitempty"`
|
||||
UploadedBy *uuid.UUID `db:"uploaded_by" json:"uploaded_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// Partei is a party to an Akte (Kläger, Beklagter, etc.).
|
||||
type Partei struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
@@ -104,31 +124,31 @@ type Partei struct {
|
||||
|
||||
// DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.).
|
||||
type DeadlineRule struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
||||
Code *string `db:"code" json:"code,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
IsMandatory bool `db:"is_mandatory" json:"is_mandatory"`
|
||||
DurationValue int `db:"duration_value" json:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
||||
Timing *string `db:"timing" json:"timing,omitempty"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||
ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"`
|
||||
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
||||
Code *string `db:"code" json:"code,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
IsMandatory bool `db:"is_mandatory" json:"is_mandatory"`
|
||||
DurationValue int `db:"duration_value" json:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
||||
Timing *string `db:"timing" json:"timing,omitempty"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||
ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"`
|
||||
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
|
||||
|
||||
195
internal/services/ai_service.go
Normal file
195
internal/services/ai_service.go
Normal file
@@ -0,0 +1,195 @@
|
||||
// Package services — Phase H: AI-powered deadline extraction.
|
||||
//
|
||||
// AIService wraps the Anthropic SDK to extract deadlines from uploaded court
|
||||
// documents (PDFs). The service is optional: if ANTHROPIC_API_KEY is unset at
|
||||
// startup, NewAIService returns nil and the handlers respond with 501.
|
||||
//
|
||||
// Design (from docs/design-kanzlai-integration.md §8 Phase H):
|
||||
// - Claude Sonnet for cost/quality balance; PDF sent directly as a document
|
||||
// content block (no preprocessing needed).
|
||||
// - Tool-forced structured output: a single `extract_deadlines` tool with a
|
||||
// strict schema, with `ToolChoice` pinned to it so the response is always
|
||||
// parseable JSON.
|
||||
// - System prompt is cached (ephemeral, 5m TTL) so repeat extractions in
|
||||
// the same session hit the cache and pay ~1/10 the prompt-token cost.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
)
|
||||
|
||||
// ErrAIDisabled is returned when the service is called but ANTHROPIC_API_KEY
|
||||
// was unset at startup. Handlers should map this to 501 Not Implemented.
|
||||
var ErrAIDisabled = errors.New("AI extraction not configured")
|
||||
|
||||
// AIService performs Claude-backed deadline extraction on document uploads.
|
||||
// A nil pointer is a valid "disabled" state — the handlers check for it.
|
||||
type AIService struct {
|
||||
client anthropic.Client
|
||||
model anthropic.Model
|
||||
}
|
||||
|
||||
// NewAIService constructs the service. Returns nil if apiKey is empty so the
|
||||
// caller can store the nil and let handlers return 501.
|
||||
func NewAIService(apiKey string) *AIService {
|
||||
if apiKey == "" {
|
||||
return nil
|
||||
}
|
||||
client := anthropic.NewClient(option.WithAPIKey(apiKey))
|
||||
return &AIService{
|
||||
client: client,
|
||||
// Sonnet 4.6 — cheapest capable model for this task. Sonnet handles
|
||||
// legal German + English well and is materially cheaper than Opus.
|
||||
model: anthropic.ModelClaudeSonnet4_6,
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractedDeadline is one item returned by Claude for the user to review
|
||||
// before persisting. Dates are YYYY-MM-DD strings (may be empty when the
|
||||
// document only gives a duration, not an absolute date).
|
||||
type ExtractedDeadline struct {
|
||||
Title string `json:"title"`
|
||||
DueDate string `json:"due_date"`
|
||||
RuleCode string `json:"rule_code"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
SourceQuote string `json:"source_quote"`
|
||||
}
|
||||
|
||||
type extractDeadlinesToolInput struct {
|
||||
Deadlines []ExtractedDeadline `json:"deadlines"`
|
||||
}
|
||||
|
||||
// extractDeadlinesTool is the tool schema Claude must call. Using forced
|
||||
// tool use guarantees structured JSON output without regex parsing.
|
||||
var extractDeadlinesTool = anthropic.ToolParam{
|
||||
Name: "extract_deadlines",
|
||||
Description: anthropic.String("Record every legal deadline, hearing date, or filing obligation identified in the document."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"deadlines": map[string]any{
|
||||
"type": "array",
|
||||
"description": "List of extracted deadlines, hearings, or filing obligations actionable by a patent lawyer.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"title": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Short actionable label (e.g. 'Statement of Defence', 'Oral hearing', 'Reply to Counterclaim').",
|
||||
},
|
||||
"due_date": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Absolute due date in YYYY-MM-DD format if determinable from the document. Empty string if the document only gives a duration without a trigger date.",
|
||||
},
|
||||
"rule_code": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Legal rule reference if identifiable (e.g. 'Rule 23 RoP', 'Rule 222 RoP', '§ 276 ZPO'). Empty string if none.",
|
||||
},
|
||||
"confidence": map[string]any{
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Confidence score 0.0 to 1.0. Only include items with confidence above 0.4.",
|
||||
},
|
||||
"source_quote": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Exact quote from the document where the deadline was identified, up to ~280 characters.",
|
||||
},
|
||||
},
|
||||
"required": []string{"title", "due_date", "rule_code", "confidence", "source_quote"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"deadlines"},
|
||||
},
|
||||
}
|
||||
|
||||
// extractionSystemPrompt is cached on the server side (ephemeral, 5m). The
|
||||
// content is intentionally stable so repeat extractions hit the cache.
|
||||
const extractionSystemPrompt = `You are a patent-law deadline extractor for German and UPC (Unified Patent Court) litigation.
|
||||
|
||||
Your task: identify every deadline, hearing date, or filing obligation in the provided document that is actionable by a patent lawyer.
|
||||
|
||||
For each item, return:
|
||||
- A short actionable title (e.g. "Statement of Defence", "Oral hearing", "Reply to Counterclaim").
|
||||
- The absolute due date in YYYY-MM-DD format when determinable. If the document only gives a duration ("within 2 months of service") without a concrete trigger date, return an empty string.
|
||||
- The legal rule reference if identifiable (e.g. "Rule 23 RoP", "§ 276 ZPO"). Empty if none.
|
||||
- A confidence score 0.0–1.0. Only include items with confidence above 0.4.
|
||||
- The exact source quote from the document (up to ~280 characters) where the deadline appears.
|
||||
|
||||
Rules:
|
||||
1. Only report items a patent lawyer would actually put in the Fristenkalender. Skip generic references like "the court may schedule a hearing in due course" unless a specific date or window is given.
|
||||
2. Do not invent dates. If the document says "within 3 months" but no trigger date is visible, leave due_date empty.
|
||||
3. Cite the original passage verbatim. Do not paraphrase the source_quote.
|
||||
4. Return bilingual documents correctly (UPC documents often mix DE/EN).
|
||||
5. If the document contains no deadlines, return an empty array.
|
||||
|
||||
Always call the extract_deadlines tool.`
|
||||
|
||||
// ExtractDeadlines sends the PDF bytes to Claude and returns the parsed
|
||||
// deadline candidates. Caller handles user review + persistence.
|
||||
//
|
||||
// The pdf must be a valid PDF byte slice. A nil/empty slice is a programming
|
||||
// error (handlers validate the magic number before calling this).
|
||||
func (s *AIService) ExtractDeadlines(ctx context.Context, pdfData []byte) ([]ExtractedDeadline, error) {
|
||||
if s == nil {
|
||||
return nil, ErrAIDisabled
|
||||
}
|
||||
if len(pdfData) == 0 {
|
||||
return nil, fmt.Errorf("empty pdf data")
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(pdfData)
|
||||
userContent := []anthropic.ContentBlockParamUnion{
|
||||
{OfDocument: &anthropic.DocumentBlockParam{
|
||||
Source: anthropic.DocumentBlockParamSourceUnion{
|
||||
OfBase64: &anthropic.Base64PDFSourceParam{
|
||||
Data: encoded,
|
||||
},
|
||||
},
|
||||
}},
|
||||
anthropic.NewTextBlock("Extract every patent-law-relevant deadline, hearing, or filing obligation from this document. Call the extract_deadlines tool."),
|
||||
}
|
||||
|
||||
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
|
||||
Model: s.model,
|
||||
MaxTokens: 4096,
|
||||
System: []anthropic.TextBlockParam{
|
||||
{
|
||||
Text: extractionSystemPrompt,
|
||||
CacheControl: anthropic.NewCacheControlEphemeralParam(),
|
||||
},
|
||||
},
|
||||
Messages: []anthropic.MessageParam{
|
||||
anthropic.NewUserMessage(userContent...),
|
||||
},
|
||||
Tools: []anthropic.ToolUnionParam{
|
||||
{OfTool: &extractDeadlinesTool},
|
||||
},
|
||||
ToolChoice: anthropic.ToolChoiceParamOfTool("extract_deadlines"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claude API call: %w", err)
|
||||
}
|
||||
|
||||
for _, block := range msg.Content {
|
||||
if block.Type == "tool_use" && block.Name == "extract_deadlines" {
|
||||
var input extractDeadlinesToolInput
|
||||
if err := json.Unmarshal(block.Input, &input); err != nil {
|
||||
return nil, fmt.Errorf("parsing tool output: %w", err)
|
||||
}
|
||||
if input.Deadlines == nil {
|
||||
return []ExtractedDeadline{}, nil
|
||||
}
|
||||
return input.Deadlines, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("claude response had no tool_use block")
|
||||
}
|
||||
381
internal/services/dokument_service.go
Normal file
381
internal/services/dokument_service.go
Normal file
@@ -0,0 +1,381 @@
|
||||
// Package services — Phase H: document upload / listing / extraction.
|
||||
//
|
||||
// DokumentService persists PDFs uploaded against an Akte. Visibility is
|
||||
// inherited from the parent Akte through AkteService.GetByID (same pattern
|
||||
// FristService and ParteienService use), so every read/write re-checks the
|
||||
// office-scoped visibility predicate.
|
||||
//
|
||||
// Blobs live in Supabase Storage bucket `paliad-documents`, path
|
||||
// `{akte_id}/{document_id}.pdf`. Metadata lives in paliad.dokumente.
|
||||
//
|
||||
// AI extraction flow:
|
||||
//
|
||||
// UploadPDF -> returns Dokument row (no extraction yet)
|
||||
// ExtractDeadlines -> downloads blob, calls AIService, caches result
|
||||
// in dokumente.ai_extracted, bumps count.
|
||||
// PersistExtractedFristen-> creates Fristen from confirmed items, links them
|
||||
// back via source_document_id.
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/models"
|
||||
)
|
||||
|
||||
// DocumentBucket is the Supabase Storage bucket holding Paliad document blobs.
|
||||
const DocumentBucket = "paliad-documents"
|
||||
|
||||
// MaxUploadBytes caps individual uploads at 20 MiB (spec §Phase H).
|
||||
const MaxUploadBytes = 20 * 1024 * 1024
|
||||
|
||||
// pdfMagic is the first four bytes of any valid PDF: `%PDF`.
|
||||
var pdfMagic = []byte{0x25, 0x50, 0x44, 0x46}
|
||||
|
||||
// DokumentService owns CRUD on paliad.dokumente plus the extraction flow.
|
||||
// StorageClient and AIService may be nil if their env vars are unset at
|
||||
// startup — the service returns ErrStorageDisabled / ErrAIDisabled and the
|
||||
// handler layer maps those to 501 with a friendly message.
|
||||
type DokumentService struct {
|
||||
db *sqlx.DB
|
||||
storage *StorageClient
|
||||
ai *AIService
|
||||
akten *AkteService
|
||||
frist *FristService
|
||||
}
|
||||
|
||||
// NewDokumentService wires the service to its dependencies. storage and ai
|
||||
// may be nil (disabled).
|
||||
func NewDokumentService(db *sqlx.DB, storage *StorageClient, ai *AIService, akten *AkteService, frist *FristService) *DokumentService {
|
||||
return &DokumentService{db: db, storage: storage, ai: ai, akten: akten, frist: frist}
|
||||
}
|
||||
|
||||
const dokumentColumns = `id, akte_id, title, doc_type, file_path, file_size, mime_type,
|
||||
ai_extracted, ai_extraction_count, ai_extracted_at, uploaded_by,
|
||||
created_at, updated_at`
|
||||
|
||||
// UploadEnabled reports whether document upload is configured (storage present).
|
||||
func (s *DokumentService) UploadEnabled() bool { return s.storage != nil }
|
||||
|
||||
// ExtractionEnabled reports whether AI extraction is configured (AI key present).
|
||||
func (s *DokumentService) ExtractionEnabled() bool { return s.ai != nil }
|
||||
|
||||
// UploadPDF validates the incoming file and writes it to storage + metadata.
|
||||
// The reader is consumed; callers typically pass r.MultipartReader part.
|
||||
func (s *DokumentService) UploadPDF(ctx context.Context, userID, akteID uuid.UUID, filename string, data io.Reader) (*models.Dokument, error) {
|
||||
if s.storage == nil {
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
// Visibility check first — never reveal whether an Akte exists.
|
||||
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filename = strings.TrimSpace(filename)
|
||||
if filename == "" {
|
||||
return nil, fmt.Errorf("%w: filename required", ErrInvalidInput)
|
||||
}
|
||||
|
||||
// Buffer the file so we can (a) check magic number, (b) check size, (c)
|
||||
// stream it to storage. 20 MiB is small enough to hold in memory on the
|
||||
// server; we reject anything larger before buffering.
|
||||
var buf bytes.Buffer
|
||||
limited := io.LimitReader(data, MaxUploadBytes+1)
|
||||
n, err := io.Copy(&buf, limited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading upload: %w", err)
|
||||
}
|
||||
if n > MaxUploadBytes {
|
||||
return nil, fmt.Errorf("%w: file exceeds 20 MB limit", ErrInvalidInput)
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("%w: empty file", ErrInvalidInput)
|
||||
}
|
||||
if !bytes.HasPrefix(buf.Bytes(), pdfMagic) {
|
||||
return nil, fmt.Errorf("%w: only PDF files accepted", ErrInvalidInput)
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
storagePath := fmt.Sprintf("%s/%s.pdf", akteID, id)
|
||||
|
||||
if err := s.storage.Upload(ctx, DocumentBucket, storagePath, "application/pdf", bytes.NewReader(buf.Bytes())); err != nil {
|
||||
return nil, fmt.Errorf("upload to storage: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
size := n
|
||||
mime := "application/pdf"
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
// best effort: remove orphan blob
|
||||
_ = s.storage.Delete(ctx, DocumentBucket, []string{storagePath})
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.dokumente
|
||||
(id, akte_id, title, file_path, file_size, mime_type,
|
||||
uploaded_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`,
|
||||
id, akteID, filename, storagePath, size, mime, userID, now,
|
||||
); err != nil {
|
||||
_ = s.storage.Delete(ctx, DocumentBucket, []string{storagePath})
|
||||
return nil, fmt.Errorf("insert dokument: %w", err)
|
||||
}
|
||||
|
||||
desc := fmt.Sprintf("Dokument \u201E%s\u201C hochgeladen", filename)
|
||||
descPtr := &desc
|
||||
if err := insertAkteEvent(ctx, tx, akteID, userID, "document_uploaded", "Dokument hochgeladen", descPtr); err != nil {
|
||||
_ = s.storage.Delete(ctx, DocumentBucket, []string{storagePath})
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
_ = s.storage.Delete(ctx, DocumentBucket, []string{storagePath})
|
||||
return nil, fmt.Errorf("commit upload: %w", err)
|
||||
}
|
||||
|
||||
return s.get(ctx, id)
|
||||
}
|
||||
|
||||
// ListForAkte returns the documents on an Akte, newest first. Visibility
|
||||
// is checked once via AkteService.
|
||||
func (s *DokumentService) ListForAkte(ctx context.Context, userID, akteID uuid.UUID) ([]models.Dokument, error) {
|
||||
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []models.Dokument
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+dokumentColumns+`
|
||||
FROM paliad.dokumente
|
||||
WHERE akte_id = $1
|
||||
ORDER BY created_at DESC`, akteID); err != nil {
|
||||
return nil, fmt.Errorf("list dokumente: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetByID fetches a single document, visibility-checked via its parent Akte.
|
||||
func (s *DokumentService) GetByID(ctx context.Context, userID, docID uuid.UUID) (*models.Dokument, error) {
|
||||
d, err := s.get(ctx, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.akten.GetByID(ctx, userID, d.AkteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Download returns a reader streaming the stored blob, plus its content type
|
||||
// and filename. Caller must close the reader.
|
||||
func (s *DokumentService) Download(ctx context.Context, userID, docID uuid.UUID) (io.ReadCloser, string, string, error) {
|
||||
if s.storage == nil {
|
||||
return nil, "", "", ErrStorageDisabled
|
||||
}
|
||||
d, err := s.GetByID(ctx, userID, docID)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if d.FilePath == nil {
|
||||
return nil, "", "", fmt.Errorf("document has no stored file")
|
||||
}
|
||||
body, contentType, err := s.storage.Download(ctx, DocumentBucket, *d.FilePath)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if d.MimeType != nil && *d.MimeType != "" {
|
||||
contentType = *d.MimeType
|
||||
}
|
||||
return body, contentType, d.Title, nil
|
||||
}
|
||||
|
||||
// ExtractDeadlines downloads the stored PDF, sends it to Claude, caches the
|
||||
// result in ai_extracted, and returns the extracted items (not yet persisted
|
||||
// as Fristen — user must confirm via PersistExtractedFristen).
|
||||
func (s *DokumentService) ExtractDeadlines(ctx context.Context, userID, docID uuid.UUID) ([]ExtractedDeadline, error) {
|
||||
if s.ai == nil {
|
||||
return nil, ErrAIDisabled
|
||||
}
|
||||
if s.storage == nil {
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
d, err := s.GetByID(ctx, userID, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.FilePath == nil {
|
||||
return nil, fmt.Errorf("document has no stored file")
|
||||
}
|
||||
|
||||
body, _, err := s.storage.Download(ctx, DocumentBucket, *d.FilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
pdfBytes, err := io.ReadAll(io.LimitReader(body, MaxUploadBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading blob: %w", err)
|
||||
}
|
||||
if len(pdfBytes) > MaxUploadBytes {
|
||||
return nil, fmt.Errorf("stored file exceeds size cap")
|
||||
}
|
||||
|
||||
items, err := s.ai.ExtractDeadlines(ctx, pdfBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the raw extraction payload on the document so the UI can show
|
||||
// "last extracted at" and re-open the review screen without hitting
|
||||
// Claude again.
|
||||
payload, _ := json.Marshal(items)
|
||||
now := time.Now().UTC()
|
||||
_, _ = s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.dokumente
|
||||
SET ai_extracted = $1,
|
||||
ai_extraction_count = ai_extraction_count + 1,
|
||||
ai_extracted_at = $2,
|
||||
updated_at = $2
|
||||
WHERE id = $3`, payload, now, docID)
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// PersistFristenInput is one row in the from-extraction request body.
|
||||
type PersistFristenInput struct {
|
||||
Title string `json:"title"`
|
||||
DueDate string `json:"due_date"` // YYYY-MM-DD
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
SourceQuote *string `json:"source_quote,omitempty"`
|
||||
}
|
||||
|
||||
// PersistExtractedFristen creates Fristen from AI-extracted items that the
|
||||
// user confirmed. Each new Frist points back at the source document via
|
||||
// source_document_id, and the rule_code (if supplied) is matched against
|
||||
// paliad.deadline_rules by code for the rule_id link.
|
||||
func (s *DokumentService) PersistExtractedFristen(ctx context.Context, userID, akteID, docID uuid.UUID, items []PersistFristenInput) ([]models.Frist, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("%w: at least one item required", ErrInvalidInput)
|
||||
}
|
||||
// Visibility gate + confirm the document really belongs to this Akte.
|
||||
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := s.GetByID(ctx, userID, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.AkteID != akteID {
|
||||
return nil, fmt.Errorf("%w: document does not belong to this Akte", ErrInvalidInput)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
ids := make([]uuid.UUID, 0, len(items))
|
||||
for _, in := range items {
|
||||
title := strings.TrimSpace(in.Title)
|
||||
if title == "" {
|
||||
return nil, fmt.Errorf("%w: title required", ErrInvalidInput)
|
||||
}
|
||||
due, err := time.Parse("2006-01-02", in.DueDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
|
||||
}
|
||||
|
||||
var ruleID *uuid.UUID
|
||||
if in.RuleCode != nil && strings.TrimSpace(*in.RuleCode) != "" {
|
||||
var id uuid.UUID
|
||||
err := tx.GetContext(ctx, &id,
|
||||
`SELECT id FROM paliad.deadline_rules WHERE code = $1 LIMIT 1`,
|
||||
strings.TrimSpace(*in.RuleCode))
|
||||
if err == nil {
|
||||
ruleID = &id
|
||||
}
|
||||
// If no match, leave rule_id null — rule_code still on notes.
|
||||
}
|
||||
|
||||
notes := buildExtractionNotes(in)
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.fristen
|
||||
(id, akte_id, title, due_date, source, rule_id, status,
|
||||
notes, source_document_id, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, 'ai_extracted', $5, 'pending',
|
||||
$6, $7, $8, $9, $9)`,
|
||||
id, akteID, title, due, ruleID, notes, docID, userID, now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert frist: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
desc := fmt.Sprintf("%d Fristen aus Dokument \u201E%s\u201C übernommen", len(items), d.Title)
|
||||
descPtr := &desc
|
||||
if err := insertAkteEvent(ctx, tx, akteID, userID, "fristen_from_document", "Fristen aus Dokument", descPtr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit extraction: %w", err)
|
||||
}
|
||||
|
||||
out := make([]models.Frist, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
f, err := s.frist.GetByID(ctx, userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildExtractionNotes bundles rule_code + source_quote into the Frist's
|
||||
// notes column so the information the user accepted stays searchable even
|
||||
// if the source document is later deleted.
|
||||
func buildExtractionNotes(in PersistFristenInput) *string {
|
||||
parts := []string{}
|
||||
if in.RuleCode != nil && strings.TrimSpace(*in.RuleCode) != "" {
|
||||
parts = append(parts, fmt.Sprintf("Regel: %s", strings.TrimSpace(*in.RuleCode)))
|
||||
}
|
||||
if in.SourceQuote != nil && strings.TrimSpace(*in.SourceQuote) != "" {
|
||||
parts = append(parts, fmt.Sprintf("Quelle: \u201E%s\u201C", strings.TrimSpace(*in.SourceQuote)))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
s := strings.Join(parts, "\n")
|
||||
return &s
|
||||
}
|
||||
|
||||
// get fetches without visibility check (caller must verify via GetByID).
|
||||
func (s *DokumentService) get(ctx context.Context, id uuid.UUID) (*models.Dokument, error) {
|
||||
var d models.Dokument
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT `+dokumentColumns+` FROM paliad.dokumente WHERE id = $1`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get dokument: %w", err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
131
internal/services/storage.go
Normal file
131
internal/services/storage.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Package services — Supabase Storage client for Paliad document uploads.
|
||||
//
|
||||
// Paliad uses Supabase Storage (youpc instance) for the PDF blobs backing
|
||||
// paliad.dokumente rows. Writes require the service-role key; the anon key
|
||||
// used for auth is not enough. If SUPABASE_SERVICE_KEY is unset at startup,
|
||||
// NewStorageClient returns nil and handlers respond with 501.
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrStorageDisabled is returned when handlers invoke a storage-backed
|
||||
// operation but SUPABASE_SERVICE_KEY was unset at startup.
|
||||
var ErrStorageDisabled = errors.New("document storage not configured")
|
||||
|
||||
// StorageClient is a thin wrapper around the Supabase Storage REST API.
|
||||
// A nil pointer is a valid "disabled" state; callers check for it.
|
||||
type StorageClient struct {
|
||||
baseURL string
|
||||
serviceKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewStorageClient returns a ready-to-use client, or nil if either baseURL
|
||||
// or serviceKey is empty. Handlers treat nil as "storage not configured".
|
||||
func NewStorageClient(baseURL, serviceKey string) *StorageClient {
|
||||
if baseURL == "" || serviceKey == "" {
|
||||
return nil
|
||||
}
|
||||
return &StorageClient{
|
||||
baseURL: baseURL,
|
||||
serviceKey: serviceKey,
|
||||
httpClient: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Upload writes a single object to the given bucket/path. Existing objects
|
||||
// at the path are replaced (x-upsert: true). contentType is required.
|
||||
func (s *StorageClient) Upload(ctx context.Context, bucket, path, contentType string, data io.Reader) error {
|
||||
if s == nil {
|
||||
return ErrStorageDisabled
|
||||
}
|
||||
url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating upload request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("x-upsert", "true")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploading to storage: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("storage upload failed (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download fetches an object. The caller must close the returned ReadCloser.
|
||||
func (s *StorageClient) Download(ctx context.Context, bucket, path string) (io.ReadCloser, string, error) {
|
||||
if s == nil {
|
||||
return nil, "", ErrStorageDisabled
|
||||
}
|
||||
url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("creating download request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("downloading from storage: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, "", fmt.Errorf("storage object not found")
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, "", fmt.Errorf("storage download failed (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return resp.Body, resp.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
// Delete removes the given paths from a bucket (Supabase accepts an array).
|
||||
func (s *StorageClient) Delete(ctx context.Context, bucket string, paths []string) error {
|
||||
if s == nil {
|
||||
return ErrStorageDisabled
|
||||
}
|
||||
url := fmt.Sprintf("%s/storage/v1/object/%s", s.baseURL, bucket)
|
||||
|
||||
body, err := json.Marshal(map[string][]string{"prefixes": paths})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling delete request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating delete request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting from storage: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("storage delete failed (status %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user