feat(tickets): Web-Push-Benachrichtigungen (PWA)
- Die Züchterin kann auf der Tickets-Seite Push-Benachrichtigungen aktivieren (🔔-Schalter). Service Worker ist BEWUSST push-only (kein fetch/Caching), damit das Laden der App nie beeinträchtigt wird. - Backend: WebPush-Bibliothek + VAPID; PushNotifier sendet an alle Abos, räumt veraltete (404/410) auf. Endpoints: GET /push/vapid-public-key, POST /push/subscribe|unsubscribe. - Ausgelöst über ein notify-Flag auf PUT /feedback: NUR die KI setzt es (z. B. neue Rückfrage / Ticket gelöst) → keine Selbst-Pushes durch Aktionen der Züchterin. Text wird aus dem Status abgeleitet, Klick öffnet das Ticket (?focus=). - Schalter/Logik blenden sich aus, wenn das Gerät kein Push kann oder der Server keine VAPID-Schlüssel hat (z. B. e2e-Mock). Migration WebPushSubscriptions. VAPID-Schlüssel in appsettings.json (lokale Single-User-App im Heimnetz — bewusste, vertretbare Vereinfachung). Tests: 264 Backend grün (+Push-Endpoints), e2e Tickets Desktop grün, vitest 149. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -91,7 +91,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, FeedbackUpdate input, ApplicationContext db) =>
|
||||
Guid id, FeedbackUpdate input, ApplicationContext db, Push.PushNotifier push) =>
|
||||
{
|
||||
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (entity is null)
|
||||
@@ -205,6 +205,28 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
entity.Helpful = input.Helpful;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push an die Züchterin, wenn die KI das anfordert (notify=true). Nachricht aus dem
|
||||
// resultierenden Status ableiten. Fehler dürfen die Antwort nicht stören.
|
||||
if (input.Notify == true && push.Enabled)
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(entity.EntityName) ? "" : $" ({entity.EntityName})";
|
||||
var (title, body) = entity.Status switch
|
||||
{
|
||||
"NeedsInfo" => ("Neue Rückfrage" + name, Trim(entity.Question) ?? "Bitte schau in deine Tickets."),
|
||||
"Resolved" => ("Ticket gelöst" + name, Trim(entity.FixNote) ?? Trim(entity.Message) ?? "Erledigt."),
|
||||
_ => ("Neues zu deinem Ticket" + name, Trim(entity.Message) ?? ""),
|
||||
};
|
||||
try
|
||||
{
|
||||
await push.NotifyAllAsync(title, body, $"/hilfe/tickets?focus={entity.Id}");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Push ist best-effort — niemals die API-Antwort daran scheitern lassen.
|
||||
}
|
||||
}
|
||||
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
@@ -309,6 +331,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>Für Push-Texte: leeren Wert zu null, sonst auf ~140 Zeichen kürzen.</summary>
|
||||
private static string? Trim(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||
var t = s.Trim();
|
||||
return t.Length > 140 ? t[..139] + "…" : t;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append the ticket's current (Question, Answer) exchange to the thread/history JSON
|
||||
/// before it gets overwritten by a new round, then clear the current Answer. Only the
|
||||
|
||||
Reference in New Issue
Block a user