feat(tickets): Kurz-Verweise (Shortlinks) im Ticket-Text auflösen

Die KI kann im Text `tier:<8hex>` / `kontakt:` / `wurf:` / `gehege:` schreiben;
Frontend löst per POST /refs/resolve (Präfix-Match, DB-agnostisch) zu Name +
internem Link auf. Reine KI-Schreibhilfe — kein Nutzer-Button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 07:23:03 +02:00
parent c7b054e909
commit 00dba91a5c
7 changed files with 407 additions and 7 deletions

View File

@@ -0,0 +1,19 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>
/// REFS: ein einzelner aufzulösender Kurz-Verweis (Shortlink, git-Stil) aus einem
/// Freitext. <c>Type</c> ist "tier" | "kontakt" | "wurf" | "gehege"; <c>Code</c> ist
/// ein GUID-Präfix (mind. 6 Hex-Zeichen) ODER die volle GUID — Bindestrich-tolerant.
/// </summary>
public record RefRequest(string Type, string Code);
/// <summary>REFS: Body von POST /refs/resolve.</summary>
public record ResolveRefsInput(List<RefRequest> Refs);
/// <summary>
/// REFS: Auflösungsergebnis. <c>Id</c>/<c>Name</c> sind null, wenn der Code unbekannt
/// ODER mehrdeutig ist (mehrere Präfix-Treffer) — es wird bewusst nicht geraten.
/// <c>Type</c>/<c>Code</c> werden gespiegelt, damit der Client zuordnen kann.
/// </summary>
public record RefResolution(string Type, string Code, Guid? Id, string? Name);
}

View File

@@ -0,0 +1,107 @@
using GerbilManagerWebAPI.Dtos;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// REFS: Auflöser für getippte Kurz-Verweise (Shortlinks, git-Stil) in Freitexten.
/// Tokens im Text: <c>tier:&lt;code&gt;</c>, <c>kontakt:&lt;code&gt;</c>,
/// <c>wurf:&lt;code&gt;</c>, <c>gehege:&lt;code&gt;</c> — <c>&lt;code&gt;</c> 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).
/// </summary>
public static class RefsEndpoints
{
/// <summary>Schlanke {Id,Name}-Projektion für den In-Memory-Präfix-Match.</summary>
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<Ok<List<RefResolution>>> (
ResolveRefsInput input, ApplicationContext db) =>
{
var refs = input.Refs ?? new List<RefRequest>();
// 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<string, List<NamedRow>>();
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<RefResolution>(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;
}
/// <summary>Akzeptiert die vier Typ-Schlüssel (case-insensitive); sonst null.</summary>
private static string? NormalizeType(string? type) => type?.Trim().ToLowerInvariant() switch
{
"tier" => "tier",
"kontakt" => "kontakt",
"wurf" => "wurf",
"gehege" => "gehege",
_ => null,
};
/// <summary>GUID-Code bindestrich-tolerant + Hex-only normalisieren (Kleinschreibung).</summary>
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());
}
}
}

View File

@@ -132,6 +132,7 @@ app.MapSaleReservationEndpoints();
app.MapWaitingListEndpoints();
app.MapReturnRecordEndpoints();
app.MapExhibitionEndpoints();
app.MapRefsEndpoints();
app.Run();