diff --git a/.gitignore b/.gitignore index 1556f74..634b2db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *~ screenshots/ +subagents-sammlung/ diff --git a/backend/alembic/versions/0023_suchindex.py b/backend/alembic/versions/0023_suchindex.py new file mode 100644 index 0000000..d531d10 --- /dev/null +++ b/backend/alembic/versions/0023_suchindex.py @@ -0,0 +1,26 @@ +"""SEARCH-001: pg_trgm-Erweiterung + GIN-Trigram-Index auf Objekt.name/code. + +Revision ID: 0023_suchindex +Revises: 0022_wartung +Create Date: 2026-09-08 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0023_suchindex" +down_revision: Union[str, None] = "0022_wartung" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + op.execute("CREATE INDEX ix_objekt_name_trgm ON objekt USING GIN (name gin_trgm_ops)") + op.execute("CREATE INDEX ix_objekt_code_trgm ON objekt USING GIN (code gin_trgm_ops)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_objekt_code_trgm") + op.execute("DROP INDEX IF EXISTS ix_objekt_name_trgm") + op.execute("DROP EXTENSION IF EXISTS pg_trgm") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index f14cd76..fcfa55e 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -20,6 +20,7 @@ from app.api.v1.endpoints import ( permission, personal, stammdaten, + suche, vorlagen, wartung, zustaendigkeit, @@ -48,3 +49,4 @@ api_router.include_router(akte.router, tags=["akte"]) api_router.include_router(lager.router, tags=["lager"]) api_router.include_router(ausgabe.router, tags=["ausgabe"]) api_router.include_router(wartung.router, tags=["wartung"]) +api_router.include_router(suche.router, tags=["suche"]) diff --git a/backend/app/api/v1/endpoints/suche.py b/backend/app/api/v1/endpoints/suche.py new file mode 100644 index 0000000..03a76df --- /dev/null +++ b/backend/app/api/v1/endpoints/suche.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.objekt import Objekt +from app.schemas.suche import SuchTreffer + +router = APIRouter() + +_TREFFER_LIMIT = 8 + + +@router.get("/suche", response_model=list[SuchTreffer]) +async def suche( + q: str, + db: AsyncSession = Depends(get_db), + _=Depends(get_current_user), +) -> list[SuchTreffer]: + """Globale Suche (Command Palette) über Objektname/-code, per pg_trgm- + Index statt vollständig geladener Objektliste + Client-Filter (SEARCH-001). + Kurze Eingaben (<2 Zeichen) liefern bewusst nichts - sonst zu viele + Zufallstreffer über den Ähnlichkeits-Schwellenwert von pg_trgm.""" + q = q.strip() + if len(q) < 2: + return [] + + muster = f"%{q}%" + aehnlichkeit = func.greatest(func.similarity(Objekt.name, q), func.similarity(Objekt.code, q)) + stmt = ( + select(Objekt.id, Objekt.code, Objekt.name) + .where(or_(Objekt.name.ilike(muster), Objekt.code.ilike(muster), aehnlichkeit > 0.2)) + .order_by(aehnlichkeit.desc()) + .limit(_TREFFER_LIMIT) + ) + result = await db.execute(stmt) + return [SuchTreffer(id=r.id, code=r.code, name=r.name) for r in result.all()] diff --git a/backend/app/schemas/suche.py b/backend/app/schemas/suche.py new file mode 100644 index 0000000..b54835c --- /dev/null +++ b/backend/app/schemas/suche.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class SuchTreffer(BaseModel): + id: int + code: str + name: str diff --git a/backend/tests/test_suche.py b/backend/tests/test_suche.py new file mode 100644 index 0000000..6d558fd --- /dev/null +++ b/backend/tests/test_suche.py @@ -0,0 +1,32 @@ +import pytest + +from tests.conftest import auth_header, login + + +@pytest.mark.asyncio +async def test_suche_findet_treffer_ueber_name_und_code(client, objekt_mit_position, mitarbeiter_user): + """SEARCH-001: Objekt per (Teil-)Name UND per Code auffindbar.""" + objekt, _ = objekt_mit_position + token = await login(client, "mitarbeiter1") + + per_name = await client.get("/api/v1/suche?q=Kontroll-Test", headers=auth_header(token)) + assert per_name.status_code == 200 + treffer_ids = [t["id"] for t in per_name.json()] + assert objekt.id in treffer_ids + + per_code = await client.get("/api/v1/suche?q=KT-01", headers=auth_header(token)) + assert objekt.id in [t["id"] for t in per_code.json()] + + +@pytest.mark.asyncio +async def test_suche_zu_kurze_eingabe_liefert_leer(client, objekt_mit_position, mitarbeiter_user): + token = await login(client, "mitarbeiter1") + antwort = await client.get("/api/v1/suche?q=K", headers=auth_header(token)) + assert antwort.status_code == 200 + assert antwort.json() == [] + + +@pytest.mark.asyncio +async def test_suche_ohne_login_abgelehnt(client, objekt_mit_position): + antwort = await client.get("/api/v1/suche?q=Kontroll") + assert antwort.status_code == 401 diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index bf6d9fa..ef5507e 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -3,7 +3,12 @@ import { useNavigate } from "react-router-dom"; import { apiRequest } from "../api/client"; import { useAuth } from "../auth/AuthContext"; -import type { Objekt } from "../api/types"; + +interface SuchTreffer { + id: number; + code: string; + name: string; +} interface Eintrag { id: string; @@ -22,7 +27,7 @@ export function CommandPalette() { const { istVerantwortlich, istAdmin } = useAuth(); const [offen, setOffen] = useState(false); const [suche, setSuche] = useState(""); - const [objekte, setObjekte] = useState([]); + const [objektTreffer, setObjektTreffer] = useState([]); useEffect(() => { function onKeyDown(e: KeyboardEvent) { @@ -44,13 +49,34 @@ export function CommandPalette() { }, []); useEffect(() => { - if (offen && objekte.length === 0) { - apiRequest("/objekte") - .then(setObjekte) - .catch(() => undefined); + if (!offen) { + setSuche(""); + setObjektTreffer([]); } - if (!offen) setSuche(""); - }, [offen, objekte.length]); + }, [offen]); + + // Debounced Backend-Suche statt kompletter Objektliste + Client-Filter + // (SEARCH-001) - vorher wurden ALLE Objekte beim Öffnen geladen und nur + // clientseitig gefiltert, was bei vielen Objekten langsam/unscharf würde. + useEffect(() => { + const trimmed = suche.trim(); + if (trimmed.length < 2) { + setObjektTreffer([]); + return; + } + let abgebrochen = false; + const timer = setTimeout(() => { + apiRequest(`/suche?q=${encodeURIComponent(trimmed)}`) + .then((treffer) => { + if (!abgebrochen) setObjektTreffer(treffer); + }) + .catch(() => undefined); + }, 250); + return () => { + abgebrochen = true; + clearTimeout(timer); + }; + }, [suche]); const aktionen = useMemo(() => { const liste: Eintrag[] = [ @@ -83,19 +109,16 @@ export function CommandPalette() { return liste; }, [navigate, istVerantwortlich, istAdmin]); - const objektTreffer = useMemo(() => { - if (suche.trim().length === 0) return []; - const s = suche.toLowerCase(); - return objekte - .filter((o) => o.name.toLowerCase().includes(s) || o.code.toLowerCase().includes(s)) - .slice(0, 8) - .map((o) => ({ + const objektEintraege = useMemo( + () => + objektTreffer.map((o) => ({ id: `objekt-${o.id}`, label: o.name, hinweis: o.code, gehe: () => navigate(`/akte/objekt/${o.id}`), - })); - }, [suche, objekte, navigate]); + })), + [objektTreffer, navigate] + ); const gefilterteAktionen = useMemo(() => { if (suche.trim().length === 0) return aktionen; @@ -105,7 +128,7 @@ export function CommandPalette() { if (!offen) return null; - const treffer = [...objektTreffer, ...gefilterteAktionen]; + const treffer = [...objektEintraege, ...gefilterteAktionen]; function auswaehlen(eintrag: Eintrag) { eintrag.gehe();