diff --git a/GerbilManager.Tests/RefsEndpointTests.cs b/GerbilManager.Tests/RefsEndpointTests.cs new file mode 100644 index 0000000..d80116a --- /dev/null +++ b/GerbilManager.Tests/RefsEndpointTests.cs @@ -0,0 +1,123 @@ +using System.Net.Http.Json; +using System.Text.Json; +using GerbilManagerWebAPI.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace GerbilManager.Tests; + +/// +/// REFS: POST /refs/resolve löst getippte Kurz-Verweise (tier|kontakt|wurf|gehege) auf. +/// - exakter (volle GUID) und kurzer (Präfix) Treffer je Typ +/// - mehrdeutiges Präfix (mehrere Treffer) => id=null (nicht geraten) +/// - unbekannter Code / unbekannter Typ => id=null +/// Entitäten werden mit deterministischen GUIDs direkt in die SQLite-Test-DB geseedet, +/// damit Präfixe und Mehrdeutigkeit kontrolliert getestet werden können. +/// +public class RefsEndpointTests : IClassFixture +{ + private readonly ApiFactory _factory; + public RefsEndpointTests(ApiFactory factory) => _factory = factory; + + // Deterministische GUIDs: zwei Tiere mit GEMEINSAMEM 6-Hex-Präfix "abcdef" (mehrdeutig), + // sowie je eine eindeutige Entität pro Typ. + private static readonly Guid TierA = new("abcdef00-0000-0000-0000-000000000001"); + private static readonly Guid TierB = new("abcdef00-0000-0000-0000-000000000002"); + private static readonly Guid Kontakt = new("11112222-3333-4444-5555-666677778888"); + private static readonly Guid Wurf = new("99990000-aaaa-bbbb-cccc-ddddeeeeffff"); + private static readonly Guid Gehege = new("12345678-9abc-def0-1234-567890abcdef"); + + private async Task SeedAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (db.Gerbils.Any(g => g.Id == TierA)) return; // einmalig (geteilte Fixture) + + db.Gerbils.Add(new Gerbil { Id = TierA, Name = "Krümel", Gender = Gender.female }); + db.Gerbils.Add(new Gerbil { Id = TierB, Name = "Möhre", Gender = Gender.male }); + db.Contacts.Add(new Contact { Id = Kontakt, Name = "Erika Musterfrau" }); + db.Litters.Add(new Litter { Id = Wurf, Name = "Wurf X", Date = new DateOnly(2026, 1, 1) }); + db.Enclosures.Add(new Enclosure { Id = Gehege, Name = "Terrarium 1" }); + await db.SaveChangesAsync(); + } + + private async Task> Resolve(params object[] refs) + { + var resp = await _factory.CreateClient() + .PostAsJsonAsync("/refs/resolve", new { refs }); + resp.EnsureSuccessStatusCode(); + var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + return doc.RootElement.EnumerateArray().ToList(); + } + + [Fact] + public async Task Resolves_full_guid_per_type() + { + await SeedAsync(); + var rows = await Resolve( + new { type = "tier", code = TierA.ToString() }, + new { type = "kontakt", code = Kontakt.ToString() }, + new { type = "wurf", code = Wurf.ToString() }, + new { type = "gehege", code = Gehege.ToString() }); + + Assert.Equal(TierA.ToString(), rows[0].GetProperty("id").GetString()); + Assert.Equal("Krümel", rows[0].GetProperty("name").GetString()); + Assert.Equal(Kontakt.ToString(), rows[1].GetProperty("id").GetString()); + Assert.Equal("Erika Musterfrau", rows[1].GetProperty("name").GetString()); + Assert.Equal(Wurf.ToString(), rows[2].GetProperty("id").GetString()); + Assert.Equal("Wurf X", rows[2].GetProperty("name").GetString()); + Assert.Equal(Gehege.ToString(), rows[3].GetProperty("id").GetString()); + Assert.Equal("Terrarium 1", rows[3].GetProperty("name").GetString()); + } + + [Fact] + public async Task Resolves_short_prefix_when_unique() + { + await SeedAsync(); + // 8-Hex-Kurz-ID des Kontakts ("11112222") ist eindeutig. + var rows = await Resolve(new { type = "kontakt", code = "11112222" }); + Assert.Equal(Kontakt.ToString(), rows[0].GetProperty("id").GetString()); + Assert.Equal("Erika Musterfrau", rows[0].GetProperty("name").GetString()); + } + + [Fact] + public async Task Tolerates_dashes_and_case_in_code() + { + await SeedAsync(); + // Gemischte Schreibweise + Bindestriche im Kurz-Code des Geheges. + var rows = await Resolve(new { type = "gehege", code = "1234-5678" }); + Assert.Equal(Gehege.ToString(), rows[0].GetProperty("id").GetString()); + Assert.Equal("Terrarium 1", rows[0].GetProperty("name").GetString()); + } + + [Fact] + public async Task Ambiguous_prefix_resolves_to_null() + { + await SeedAsync(); + // "abcdef" trifft TierA UND TierB => mehrdeutig => id/name null. + var rows = await Resolve(new { type = "tier", code = "abcdef" }); + Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind); + Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("name").ValueKind); + Assert.Equal("tier", rows[0].GetProperty("type").GetString()); + Assert.Equal("abcdef", rows[0].GetProperty("code").GetString()); + } + + [Fact] + public async Task Unknown_code_and_unknown_type_resolve_to_null() + { + await SeedAsync(); + var rows = await Resolve( + new { type = "tier", code = "fffffffe" }, // unbekannter Code + new { type = "schubladen", code = "11112222" }); // unbekannter Typ + Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind); + Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("id").ValueKind); + } + + [Fact] + public async Task Too_short_code_resolves_to_null() + { + await SeedAsync(); + // < 6 Hex-Zeichen wird nicht aufgelöst (auch wenn es eindeutig wäre). + var rows = await Resolve(new { type = "kontakt", code = "1111" }); + Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind); + } +} diff --git a/GerbilManagerWebAPI/Dtos/RefsDtos.cs b/GerbilManagerWebAPI/Dtos/RefsDtos.cs new file mode 100644 index 0000000..4276442 --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/RefsDtos.cs @@ -0,0 +1,19 @@ +namespace GerbilManagerWebAPI.Dtos +{ + /// + /// REFS: ein einzelner aufzulösender Kurz-Verweis (Shortlink, git-Stil) aus einem + /// Freitext. Type ist "tier" | "kontakt" | "wurf" | "gehege"; Code ist + /// ein GUID-Präfix (mind. 6 Hex-Zeichen) ODER die volle GUID — Bindestrich-tolerant. + /// + public record RefRequest(string Type, string Code); + + /// REFS: Body von POST /refs/resolve. + public record ResolveRefsInput(List Refs); + + /// + /// REFS: Auflösungsergebnis. Id/Name sind null, wenn der Code unbekannt + /// ODER mehrdeutig ist (mehrere Präfix-Treffer) — es wird bewusst nicht geraten. + /// Type/Code werden gespiegelt, damit der Client zuordnen kann. + /// + public record RefResolution(string Type, string Code, Guid? Id, string? Name); +} diff --git a/GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs b/GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs new file mode 100644 index 0000000..b76b48a --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs @@ -0,0 +1,107 @@ +using GerbilManagerWebAPI.Dtos; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// REFS: Auflöser für getippte Kurz-Verweise (Shortlinks, git-Stil) in Freitexten. + /// Tokens im Text: tier:<code>, kontakt:<code>, + /// wurf:<code>, gehege:<code><code> ist ein + /// GUID-Präfix (mind. 6 Hex) oder die volle GUID. Das Frontend rendert daraus + /// Name + interner Link. + /// + /// POST /refs/resolve -> [{ type, code, id|null, name|null }] + /// + /// DB-AGNOSTISCH (Tests: SQLite in-memory, Prod: Postgres): pro Typ eine schlanke + /// {Id,Name}-Projektion laden und im Speicher per Präfix matchen — KEIN ::text-Cast + /// o.ä. Mehrdeutiges Präfix (mehrere Treffer) => id=null (nicht raten). + /// + public static class RefsEndpoints + { + /// Schlanke {Id,Name}-Projektion für den In-Memory-Präfix-Match. + private readonly record struct NamedRow(Guid Id, string Name); + + public static IEndpointRouteBuilder MapRefsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/refs").WithTags("Refs"); + + group.MapPost("/resolve", async Task>> ( + ResolveRefsInput input, ApplicationContext db) => + { + var refs = input.Refs ?? new List(); + + // Welche Entitätstypen werden überhaupt angefragt? Nur diese Tabellen laden. + var wantedTypes = refs + .Select(r => NormalizeType(r.Type)) + .Where(t => t is not null) + .Distinct() + .ToHashSet(); + + // Pro Typ einmalig eine leichte {Id,Name}-Projektion laden (DB-agnostisch: + // reine Spaltenauswahl, kein ::text-Cast — läuft auf SQLite wie auf Postgres). + var byType = new Dictionary>(); + if (wantedTypes.Contains("tier")) + byType["tier"] = await db.Gerbils.AsNoTracking() + .Select(x => new NamedRow(x.Id, x.Name)).ToListAsync(); + if (wantedTypes.Contains("kontakt")) + byType["kontakt"] = await db.Contacts.AsNoTracking() + .Select(x => new NamedRow(x.Id, x.Name)).ToListAsync(); + if (wantedTypes.Contains("wurf")) + byType["wurf"] = await db.Litters.AsNoTracking() + .Select(x => new NamedRow(x.Id, x.Name)).ToListAsync(); + if (wantedTypes.Contains("gehege")) + byType["gehege"] = await db.Enclosures.AsNoTracking() + .Select(x => new NamedRow(x.Id, x.Name)).ToListAsync(); + + var results = new List(refs.Count); + foreach (var r in refs) + { + var type = NormalizeType(r.Type); + var prefix = NormalizeCode(r.Code); + + if (type is null || prefix.Length < 6 || !byType.TryGetValue(type, out var rows)) + { + results.Add(new RefResolution(r.Type, r.Code, null, null)); + continue; + } + + // Präfix-Match auf der bindestrich-losen, klein geschriebenen GUID ("N"-Format). + var matches = rows + .Where(x => x.Id.ToString("N").StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Take(2) + .ToList(); + + // Genau ein Treffer => auflösen; 0 (unbekannt) oder >1 (mehrdeutig) => null. + if (matches.Count == 1) + results.Add(new RefResolution(r.Type, r.Code, matches[0].Id, matches[0].Name)); + else + results.Add(new RefResolution(r.Type, r.Code, null, null)); + } + + return TypedResults.Ok(results); + }); + + return app; + } + + /// Akzeptiert die vier Typ-Schlüssel (case-insensitive); sonst null. + private static string? NormalizeType(string? type) => type?.Trim().ToLowerInvariant() switch + { + "tier" => "tier", + "kontakt" => "kontakt", + "wurf" => "wurf", + "gehege" => "gehege", + _ => null, + }; + + /// GUID-Code bindestrich-tolerant + Hex-only normalisieren (Kleinschreibung). + private static string NormalizeCode(string? code) + { + if (string.IsNullOrWhiteSpace(code)) return string.Empty; + var chars = code.Trim().ToLowerInvariant() + .Where(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')); + return new string(chars.ToArray()); + } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 8b0667f..788f1e4 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -132,6 +132,7 @@ app.MapSaleReservationEndpoints(); app.MapWaitingListEndpoints(); app.MapReturnRecordEndpoints(); app.MapExhibitionEndpoints(); +app.MapRefsEndpoints(); app.Run(); diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 0fbbb01..8d237ac 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -795,6 +795,34 @@ export async function installMockApi(page: Page): Promise { return json(route, 405) } + // REFS: getippte Kurz-Verweise (Shortlinks) auflösen. Spiegelt das Backend: + // Präfix-Match (>=6 Hex, bindestrich-tolerant) auf der bindestrich-losen Id; + // mehrdeutig/unbekannt => id/name null. + if (path === '/refs/resolve' && method === 'POST') { + const body = request.postDataJSON() as { refs?: { type?: string; code?: string }[] } + const tableFor: Record = { + tier: db.gerbils as unknown as Row[], + kontakt: db.contacts as unknown as Row[], + wurf: db.litters as unknown as Row[], + gehege: db.enclosures as unknown as Row[], + } + const normCode = (code: string) => (code ?? '').toLowerCase().replace(/[^0-9a-f]/g, '') + const results = (body.refs ?? []).map((r) => { + const type = (r.type ?? '').toLowerCase() + const prefix = normCode(r.code ?? '') + const rows = tableFor[type] + if (!rows || prefix.length < 6) return { type: r.type, code: r.code, id: null, name: null } + const matches = rows.filter((row) => + String(row.id ?? '').toLowerCase().replace(/-/g, '').startsWith(prefix), + ) + if (matches.length === 1) { + return { type: r.type, code: r.code, id: String(matches[0].id), name: String(matches[0].name ?? '') } + } + return { type: r.type, code: r.code, id: null, name: null } + }) + return json(route, 200, results) + } + // Generische Kollektionen: / und // m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) const col = m ? collections[m[1]] : undefined diff --git a/gerbil-manager-web/src/api/refs.ts b/gerbil-manager-web/src/api/refs.ts new file mode 100644 index 0000000..542f9bb --- /dev/null +++ b/gerbil-manager-web/src/api/refs.ts @@ -0,0 +1,51 @@ +/** + * REFS: API-Client für getippte Kurz-Verweise (Shortlinks, git-Stil) in Freitexten. + * + * Tokens im Text: `tier:`, `kontakt:`, `wurf:`, `gehege:` — + * `` ist ein GUID-Präfix (mind. 6 Hex) oder die volle GUID. Das Backend löst + * sie zu { id, name } auf (mehrdeutig/unbekannt => id=null); das Frontend rendert + * daraus Name + internen Link zur jeweiligen Detailseite. + */ +import { api } from './client' + +/** Die vier auflösbaren Entitätstypen (Token-Präfix vor dem ":"). */ +export type RefType = 'tier' | 'kontakt' | 'wurf' | 'gehege' + +export interface RefRequest { + type: RefType + code: string +} + +export interface RefResolution { + type: string + code: string + /** Volle GUID oder null (unbekannt ODER mehrdeutig — nicht geraten). */ + id: string | null + name: string | null +} + +/** Routen-Präfix je Typ für den internen Link (siehe App.tsx). */ +export const REF_ROUTE: Record = { + tier: '/rennmaeuse', + kontakt: '/kontakte', + wurf: '/wuerfe', + gehege: '/gehege', +} +// Hinweis: Frontend-Routen weichen vom Token-Namen ab (wurf→/wuerfe), daher die Map. + +/** Token-Regex: `tier|kontakt|wurf|gehege` ":" gefolgt von >=6 Hex (oder voller GUID mit "-"). */ +export const REF_TOKEN_RE = /\b(tier|kontakt|wurf|gehege):([0-9a-fA-F]{6,}(?:-[0-9a-fA-F-]+)?)/g + +/** Erste 8 Hex-Stellen (erster GUID-Block) als Kurz-ID — wie eine kurze Git-Commit-ID. */ +export function shortCode(id: string): string { + return id.replace(/-/g, '').slice(0, 8) +} + +/** Vollständiges Token für die Zwischenablage, z. B. "tier:3c5b747b". */ +export function shortToken(type: RefType, id: string): string { + return `${type}:${shortCode(id)}` +} + +export function resolveRefs(refs: RefRequest[]): Promise { + return api.post('/refs/resolve', { refs }) +} diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx index 7f8b01e..c526a36 100644 --- a/gerbil-manager-web/src/pages/TicketsPage.tsx +++ b/gerbil-manager-web/src/pages/TicketsPage.tsx @@ -22,21 +22,32 @@ import { type FeedbackTicket, } from '../api/feedback' import { getGerbil } from '../api/gerbils' +import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs' import { useApi } from '../hooks/useApi' import { useToast } from '../components/toast' import './tickets.css' +/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */ +type ResolvedRef = { id: string; name: string } | null + /** * Nachschlage-Kontext für Ticket-Text-Links, damit RichText nicht durch jede Ebene * durchgereicht werden muss: * - ticketIds: bekannte Ticket-IDs (nackte GUID → Link auf das Ticket). * - gerbilNames: aufgelöste Tier-Namen je GerbilId (nackte GUID → Name + Link zur Akte). - * Wert `undefined` = noch nicht geladen, `null` = nicht gefunden. + * - refNames: aufgelöste Kurz-Verweise je `typ:code` (die KI kann `tier:3c5b747b` schreiben + * → Name + Link). Wert `undefined` = noch nicht geladen, `null` = unbekannt/mehrdeutig. */ const TicketRefsContext = createContext<{ ticketIds: Set gerbilNames: Record -}>({ ticketIds: new Set(), gerbilNames: {} }) + refNames: Record +}>({ ticketIds: new Set(), gerbilNames: {}, refNames: {} }) + +/** Cache-Schlüssel für einen Kurz-Verweis. */ +function refKey(type: string, code: string): string { + return `${type.toLowerCase()}:${code.replace(/-/g, '').toLowerCase()}` +} const t = de.feedback.tickets const ctx = de.feedback.contexts @@ -112,11 +123,13 @@ function TextWithBreaks({ value }: { value: string }) { ) } -/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Tier-/Ticket-IDs, Zeilenumbrüche. */ +/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Kurz-Verweise (tier:…), IDs, Umbrüche. */ function InlineMarkdown({ text }: { text: string }) { - const { ticketIds, gerbilNames } = useContext(TicketRefsContext) + const { ticketIds, gerbilNames, refNames } = useContext(TicketRefsContext) + // Gruppen: 1/2 = Markdown-Link, 3 = fett, 4 = code, 5/6 = Kurz-Verweis (typ/code), 7 = nackte GUID. const re = new RegExp( - `\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|(${GUID_RE})`, + `\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|` + + `\\b(tier|kontakt|wurf|gehege):([0-9a-fA-F]{6,}(?:-[0-9a-fA-F-]+)?)|(${GUID_RE})`, 'g', ) const nodes: ReactNode[] = [] @@ -146,8 +159,21 @@ function InlineMarkdown({ text }: { text: string }) { {m[4]} , ) + } else if (m[5] !== undefined && m[6] !== undefined) { + // Kurz-Verweis tier:/kontakt:/wurf:/gehege: → aufgelöster Name + Link, sonst Roh-Text. + const type = m[5] as RefType + const resolved = refNames[refKey(type, m[6])] + if (resolved) { + nodes.push( + + {resolved.name} + , + ) + } else { + nodes.push() + } } else { - const guid = m[5] + const guid = m[7] const gn = gerbilNames[guid] if (ticketIds.has(guid)) { nodes.push( @@ -269,6 +295,8 @@ export default function TicketsPage() { const ticketIds = useMemo(() => new Set((rows ?? []).map((r) => r.id)), [rows]) // Aufgelöste Tier-Namen je GerbilId (für nackte Tier-IDs im Text → Name + Link). const [gerbilNames, setGerbilNames] = useState>({}) + // Aufgelöste Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) je `typ:code`. + const [refNames, setRefNames] = useState>({}) // Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips). const counts = useMemo(() => { @@ -343,6 +371,49 @@ export default function TicketsPage() { } }, [candidateGerbilIds, gerbilNames]) + // Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) in den sichtbaren Tickets sammeln und + // einmalig über POST /refs/resolve auflösen — so kann die KI im Text die Kurz-ID nutzen. + const candidateRefs = useMemo(() => { + const map = new Map() + const add = (txt?: string | null) => { + if (!txt) return + const re = new RegExp(REF_TOKEN_RE.source, REF_TOKEN_RE.flags) + let m: RegExpExecArray | null + while ((m = re.exec(txt)) !== null) { + const type = m[1] as RefType + map.set(refKey(type, m[2]), { type, code: m[2] }) + } + } + for (const tk of visible) { + add(tk.message); add(tk.question); add(tk.answer); add(tk.fixNote) + for (const e of tk.thread) add(e.text) + } + return map + }, [visible]) + + useEffect(() => { + const missing = [...candidateRefs].filter(([key]) => !(key in refNames)) + if (missing.length === 0) return + let cancelled = false + resolveRefs(missing.map(([, r]) => r)) + .then((results) => { + if (cancelled) return + setRefNames((prev) => { + const next = { ...prev } + for (const r of results) { + next[refKey(r.type, r.code)] = r.id && r.name ? { id: r.id, name: r.name } : null + } + return next + }) + }) + .catch(() => { + /* Auflösung fehlgeschlagen — Tokens bleiben Roh-Text. */ + }) + return () => { + cancelled = true + } + }, [candidateRefs, refNames]) + const emptyText: Record = { dialog: t.emptyDialog, open: t.emptyOpen, @@ -386,7 +457,7 @@ export default function TicketsPage() { {visible.length === 0 ? (

{emptyText[activeView]}

) : ( - +
    {visible.map((ticket) => (