Files
nebula/src/app/api/search/route.ts
T
florian.wilde afe57c4428
CI / pruefen (pull_request) Canceled after 0s
Suche ueber Aufgaben
Zwei Luecken, die im Issue zusammen stehen.

**Im Aufgabendashboard fehlte ein Suchfeld.** Es gab Filter nach Status und
Projekt, aber keine Moeglichkeit, nach einem Wort zu suchen. Das Suchfeld
deckt Titel, Beschreibung, Labels und Projektname ab. Gefiltert wird im
Browser - die Aufgaben liegen dort ohnehin alle vor, es braucht also keine
zusaetzliche Abfrage und es gibt keine Verzoegerung beim Tippen.

Wer sucht, sucht dabei auch in Erledigtem: sonst faende man die Aufgabe von
letzter Woche nicht wieder, obwohl man genau sie meint. Der Schalter
"Erledigte einblenden" gilt nur ohne Suchbegriff.

**Die Volltextsuche kannte nur Notizen.** Task ist jetzt ebenfalls dabei,
nach demselben Muster: deutsche Textsuche, ts_headline fuer den Ausschnitt,
Hervorhebung als [[…]] statt als HTML. Deutsche Stammformen greifen damit
auch hier - "Rechnung" findet "Rechnungen".

Dazu ein Ausdrucksindex Task_fulltext_idx wie Note_fulltext_idx. Der
Ausdruck muss Zeichen fuer Zeichen dem in der Abfrage entsprechen, sonst
greift er nicht; nachgeprueft mit EXPLAIN, der Plan zeigt einen
Bitmap Index Scan darauf. "prisma migrate diff" laesst ihn in Ruhe -
Gegenprobe kommt leer heraus.

Das Antwortfeld heisst weiterhin "hits" und meint die Notizen. Umbenennen
haette nur Aufrufer gebrochen, ohne etwas zu verbessern; Aufgaben kommen als
eigenes Feld dazu. Beide Stellen, die Treffer anzeigen - Projektbrett und
Schnellsuche -, zeigen sie jetzt.

Geprueft: 261 Unit-Tests, 36 E2E-Tests. Die Volltextsuche zusaetzlich gegen
die laufende Anwendung: Treffer in Titel und Beschreibung, Erledigtes
inbegriffen, Stammformen greifen, fremde Konten finden nichts.

Refs #4
2026-08-18 03:39:02 +02:00

119 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
interface Hit {
projectId: string;
noteId: string;
title: string | null;
/** Textausschnitt mit hervorgehobener Fundstelle. */
snippet: string;
}
interface TaskHit {
projectId: string;
taskId: string;
title: string;
status: string;
/** Ausschnitt aus der Beschreibung; leer, wenn nur der Titel passte. */
snippet: string;
}
/**
* Volltextsuche ueber Notizen und Aufgaben.
*
* Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt
* Name, Kurzbeschreibung und Tags ab. Notizen und Aufgaben liegen dort nicht
* vor – die mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser
* Endpunkt: Postgres sucht mit seiner deutschen Textsuche direkt in der
* Datenbank.
*
* ts_headline liefert gleich den passenden Ausschnitt mit; die Hervorhebung
* kommt als [[…]] zurueck, damit die Oberflaeche kein HTML einbetten muss.
*
* Das Feld heisst weiterhin "hits" und meint die Notizen – umbenennen haette
* nur Aufrufer gebrochen, ohne etwas zu verbessern. Aufgaben kommen als
* eigenes Feld dazu.
*/
export async function GET(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const query = (new URL(request.url).searchParams.get("q") ?? "").trim();
if (query.length < 2) {
return NextResponse.json({ query, hits: [], tasks: [], projectIds: [] });
}
// websearch_to_tsquery versteht Eingaben wie "docker -compose" oder
// Anfuehrungszeichen und wirft bei Unsinn keinen Fehler.
const hits = await prisma.$queryRaw<Hit[]>`
SELECT
n."projectId" AS "projectId",
n."id" AS "noteId",
n."title" AS "title",
ts_headline(
'german',
n."content",
websearch_to_tsquery('german', ${query}),
'StartSel=[[, StopSel=]], MaxWords=18, MinWords=6, ShortWord=2, MaxFragments=1'
) AS "snippet"
FROM "Note" n
JOIN "Project" p ON p."id" = n."projectId"
WHERE p."ownerId" = ${auth.user.id}
AND to_tsvector('german', coalesce(n."title", '') || ' ' || n."content")
@@ websearch_to_tsquery('german', ${query})
ORDER BY ts_rank(
to_tsvector('german', coalesce(n."title", '') || ' ' || n."content"),
websearch_to_tsquery('german', ${query})
) DESC
LIMIT 50
`;
/**
* Dieselbe Rechnung fuer Aufgaben.
*
* Der Ausdruck in to_tsvector muss Zeichen fuer Zeichen dem in der
* Migration entsprechen (Task_fulltext_idx), sonst greift der Index nicht
* und Postgres rechnet ihn je Zeile neu.
*
* Erledigtes bleibt drin: wer sucht, sucht oft gerade das, was er letzte
* Woche abgehakt hat.
*/
const tasks = await prisma.$queryRaw<TaskHit[]>`
SELECT
t."projectId" AS "projectId",
t."id" AS "taskId",
t."title" AS "title",
t."status"::text AS "status",
ts_headline(
'german',
coalesce(t."description", ''),
websearch_to_tsquery('german', ${query}),
'StartSel=[[, StopSel=]], MaxWords=18, MinWords=6, ShortWord=2, MaxFragments=1'
) AS "snippet"
FROM "Task" t
JOIN "Project" p ON p."id" = t."projectId"
WHERE p."ownerId" = ${auth.user.id}
AND to_tsvector('german', coalesce(t."title", '') || ' ' || coalesce(t."description", ''))
@@ websearch_to_tsquery('german', ${query})
ORDER BY ts_rank(
to_tsvector('german', coalesce(t."title", '') || ' ' || coalesce(t."description", '')),
websearch_to_tsquery('german', ${query})
) DESC
LIMIT 50
`;
return NextResponse.json({
query,
hits,
tasks,
// Die Oberflaeche braucht vor allem die betroffenen Projekte, um sie im
// Board einzublenden – jetzt aus beiden Quellen.
projectIds: Array.from(
new Set([...hits.map((hit) => hit.projectId), ...tasks.map((hit) => hit.projectId)]),
),
});
}