feat(PROJ-13,PROJ-42): REST API v1 + Gespeicherte Suchanfragen

PROJ-13: Externe REST API für CRM/ERP-Anbindung
- API-Key Middleware mit SHA-256-Hash-Lookup + Token-Bucket Rate-Limiter
- GET /api/v1/mails — Suche mit Paginierung (max 100/Seite)
- GET /api/v1/mails/{id} — Mail-Metadaten als JSON
- GET /api/v1/mails/{id}/raw — Original-EML Download
- Admin-Endpoints: POST/GET/DELETE /api/admin/apikeys
- Tenant-Isolation, Audit-Log, 405 für non-GET Methoden

PROJ-42: Gespeicherte Suchanfragen
- Tabelle saved_searches (user_id, tenant_id, name, query_json)
- GET/POST/DELETE /api/searches/saved mit Ownership-Check
- Frontend: "Suche speichern"-Button + Popover mit gespeicherten Suchen
- shadcn/ui Komponenten, Loading/Empty States

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-04-06 10:54:26 +02:00
parent 9298216ce0
commit 3b05e949dd
15 changed files with 1400 additions and 251 deletions
+179 -1
View File
@@ -3,7 +3,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { searchEmails, exportMailsZIP, exportEDiscovery, uploadMailFilesUser, getUploadProgressUser, type SearchHit, type UploadJob } from "@/lib/api";
import { searchEmails, exportMailsZIP, exportEDiscovery, uploadMailFilesUser, getUploadProgressUser, listSavedSearches, createSavedSearch, deleteSavedSearch, type SearchHit, type UploadJob, type SavedSearch } from "@/lib/api";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -18,6 +18,11 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -36,6 +41,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Bookmark, BookmarkPlus, Trash2 } from "lucide-react";
const PAGE_SIZE = 25;
@@ -80,6 +86,14 @@ export default function SearchPage() {
const [uploadLoading, setUploadLoading] = useState(false);
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Saved searches state
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>([]);
const [savedLoading, setSavedLoading] = useState(false);
const [savePopoverOpen, setSavePopoverOpen] = useState(false);
const [saveName, setSaveName] = useState("");
const [saving, setSaving] = useState(false);
const [savedListOpen, setSavedListOpen] = useState(false);
// Clear selection when results change
useEffect(() => {
setSelected(new Set());
@@ -236,6 +250,89 @@ export default function SearchPage() {
setUploadLoading(false);
}
// Load saved searches on mount
useEffect(() => {
if (!user || user.role === "superadmin") return;
setSavedLoading(true);
listSavedSearches()
.then((list) => setSavedSearches(list || []))
.catch(() => setSavedSearches([]))
.finally(() => setSavedLoading(false));
}, [user]);
const hasActiveSearch = !!(query || fromFilter || toFilter || dateFrom || dateTo || hasAttachment);
function buildCurrentQuery(): Record<string, string> {
const q: Record<string, string> = {};
if (query) q.q = query;
if (fromFilter) q.from = fromFilter;
if (toFilter) q.to = toFilter;
if (dateFrom) q.date_from = dateFrom;
if (dateTo) q.date_to = dateTo;
if (hasAttachment) q.has_attachment = "true";
return q;
}
async function handleSaveSearch() {
if (!saveName.trim()) return;
setSaving(true);
try {
const saved = await createSavedSearch(saveName.trim(), buildCurrentQuery());
setSavedSearches((prev) => [saved, ...prev]);
setSaveName("");
setSavePopoverOpen(false);
} catch (e) {
alert(`Suche speichern fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
} finally {
setSaving(false);
}
}
function handleApplySavedSearch(s: SavedSearch) {
setQuery(s.query.q || "");
setFromFilter(s.query.from || "");
setToFilter(s.query.to || "");
setDateFrom(s.query.date_from || "");
setDateTo(s.query.date_to || "");
setHasAttachment(s.query.has_attachment === "true" ? true : undefined);
setSavedListOpen(false);
// Trigger search after state updates
setTimeout(() => {
// We need to search with the saved query directly since state isn't updated yet
setSearching(true);
searchEmails({
q: s.query.q || undefined,
from: s.query.from || undefined,
to: s.query.to || undefined,
date_from: s.query.date_from || undefined,
date_to: s.query.date_to || undefined,
has_attachment: s.query.has_attachment === "true" ? true : undefined,
page: 1,
page_size: PAGE_SIZE,
})
.then((res) => {
setResults(res.hits || []);
setTotal(res.total);
setPage(1);
setSearched(true);
})
.catch(() => {
setResults([]);
setTotal(0);
})
.finally(() => setSearching(false));
}, 0);
}
async function handleDeleteSavedSearch(id: number) {
try {
await deleteSavedSearch(id);
setSavedSearches((prev) => prev.filter((s) => s.id !== id));
} catch (e) {
alert(`Loeschen fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
}
}
const totalPages = Math.ceil(total / PAGE_SIZE);
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
@@ -269,6 +366,87 @@ export default function SearchPage() {
<Button type="button" variant="outline" onClick={() => setUploadOpen(true)}>
Importieren
</Button>
{hasActiveSearch && (
<Popover open={savePopoverOpen} onOpenChange={setSavePopoverOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="icon" title="Suche speichern" aria-label="Suche speichern">
<BookmarkPlus className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<div className="space-y-3">
<p className="text-sm font-medium">Suche speichern</p>
<Input
placeholder="Name der Suche..."
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSaveSearch(); }}
aria-label="Name der gespeicherten Suche"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setSavePopoverOpen(false)}>
Abbrechen
</Button>
<Button size="sm" onClick={handleSaveSearch} disabled={saving || !saveName.trim()}>
{saving ? "Speichern..." : "Speichern"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
<Popover open={savedListOpen} onOpenChange={setSavedListOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="icon" title="Gespeicherte Suchen" aria-label="Gespeicherte Suchen">
<Bookmark className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<p className="text-sm font-medium mb-3">Gespeicherte Suchen</p>
{savedLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : savedSearches.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
Keine gespeicherten Suchen vorhanden.
</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{savedSearches.map((s) => (
<div
key={s.id}
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted group"
>
<button
type="button"
className="flex-1 text-left text-sm truncate"
onClick={() => handleApplySavedSearch(s)}
title={Object.entries(s.query).map(([k, v]) => `${k}: ${v}`).join(", ")}
>
{s.name}
</button>
<span className="text-xs text-muted-foreground whitespace-nowrap hidden group-hover:inline">
{new Date(s.created_at).toLocaleDateString("de-DE")}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => { e.stopPropagation(); handleDeleteSavedSearch(s.id); }}
aria-label={`Suche "${s.name}" loeschen`}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
+7
View File
@@ -156,3 +156,10 @@ export {
generateSelfSignedCert,
requestACMECert,
} from "./system";
export type { SavedSearch } from "./saved_searches";
export {
listSavedSearches,
createSavedSearch,
deleteSavedSearch,
} from "./saved_searches";
+28
View File
@@ -0,0 +1,28 @@
import { request } from "./core";
export interface SavedSearch {
id: number;
name: string;
query: Record<string, string>;
created_at: string;
}
export async function listSavedSearches(): Promise<SavedSearch[]> {
return request<SavedSearch[]>("/api/searches/saved");
}
export async function createSavedSearch(
name: string,
query: Record<string, string>
): Promise<SavedSearch> {
return request<SavedSearch>("/api/searches/saved", {
method: "POST",
body: JSON.stringify({ name, query }),
});
}
export async function deleteSavedSearch(id: number): Promise<void> {
await request<Record<string, never>>(`/api/searches/saved/${id}`, {
method: "DELETE",
});
}