feat: show who added a shopping item, confirm implausible child entries

This commit is contained in:
Jannis committed 2026-10-10 18:50:53 +02:00
1 parent b7dac20fc2
commit 545162479c
9 files changed
+174 -27

No files matched your search

@@ -282,7 +282,7 @@ public static class MealEndpoints
{
var suggestion = suggestions.GetValueOrDefault(ShoppingSuggestion.Normalize(item.Name));
await ShoppingEndpoints.TrackSuggestionAsync(db, suggestion, item, manualCategory: false, timeProvider, loggerFactory, ct);
await hub.Clients.Group(LiveHub.ShoppingGroup).SendAsync(ShoppingEvents.ItemChanged, ShoppingEndpoints.ToDto(item), ct);
await hub.Clients.Group(LiveHub.ShoppingGroup).SendAsync(ShoppingEvents.ItemChanged, await ShoppingEndpoints.ToDtoAsync(db, item, ct), ct);
}
}
@@ -136,12 +136,14 @@ public static class ShoppingEndpoints
}
var items = await db.ShoppingItems.AsNoTracking().Where(i => i.ListId == id).ToListAsync(ct);
return TypedResults.Ok(Sort(items).Select(ToDto).ToList());
var names = await UserNamesAsync(db, items.Select(i => i.CreatedBy), ct);
return TypedResults.Ok(Sort(items).Select(i => ToDto(i, names)).ToList());
}
internal static async Task<Results<Created<ShoppingItemDto>, Ok<ShoppingItemDto>, NoContent, NotFound, ProblemHttpResult, ValidationProblem>> AddItemAsync(
Guid id,
AddShoppingItemRequest request,
ClaimsPrincipal user,
AppDbContext db,
IHubContext<LiveHub> hub,
TimeProvider timeProvider,
@@ -158,7 +160,7 @@ public static class ShoppingEndpoints
}
// Bereits gelöscht: nicht wieder auferstehen lassen.
return existing.IsDeleted ? TypedResults.NoContent() : TypedResults.Ok(ToDto(existing));
return existing.IsDeleted ? TypedResults.NoContent() : TypedResults.Ok(await ToDtoAsync(db, existing, ct));
}
if (!await db.ShoppingLists.AnyAsync(l => l.Id == id, ct))
@@ -174,6 +176,18 @@ public static class ShoppingEndpoints
title: "Eingaben prüfen");
}
// Plausibilitätsprüfung nur für Kinder: offensichtlicher Unsinn wird erst nach Rückfrage gespeichert.
if (!request.Confirm
&& ShoppingPlausibility.AppliesTo(user.FindAll(ClaimTypes.Role).Select(c => c.Value))
&& ShoppingPlausibility.IsSuspicious(request.Text))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict,
title: "Rückfrage nötig",
detail: "Das sieht nicht nach einem Artikel aus. Soll es trotzdem auf die Liste?",
extensions: new Dictionary<string, object?> { [ShoppingProblemExtensions.NeedsConfirmation] = true });
}
var normalized = ShoppingSuggestion.Normalize(parsed.Name);
var suggestion = await db.ShoppingSuggestions.SingleOrDefaultAsync(s => s.NormalizedName == normalized, ct);
var item = new ShoppingItem
@@ -189,7 +203,7 @@ public static class ShoppingEndpoints
await TrackSuggestionAsync(db, suggestion, item, manualCategory: request.Category is not null, timeProvider, loggerFactory, ct);
var dto = ToDto(item);
var dto = await ToDtoAsync(db, item, ct);
await hub.Clients.Group(LiveHub.ShoppingGroup).SendAsync(ShoppingEvents.ItemChanged, dto, ct);
return TypedResults.Created($"/api/shopping/items/{item.Id}", dto);
}
@@ -213,7 +227,7 @@ public static class ShoppingEndpoints
var changesContent = request.Name is not null || request.Quantity is not null || request.Category is not null;
if (changesContent && request.Version is { Length: > 0 } version && !version.AsSpan().SequenceEqual(item.RowVersion))
{
return TypedResults.Conflict(ToDto(item)); // Server gewinnt, Client übernimmt den aktuellen Stand (ADR-0011)
return TypedResults.Conflict(await ToDtoAsync(db, item, ct)); // Server gewinnt, Client übernimmt den aktuellen Stand (ADR-0011)
}
if (request.Name is not null)
@@ -246,7 +260,7 @@ public static class ShoppingEndpoints
catch (DbUpdateConcurrencyException)
{
var current = await db.ShoppingItems.AsNoTracking().SingleOrDefaultAsync(i => i.Id == id, ct);
return current is null ? TypedResults.NotFound() : TypedResults.Conflict(ToDto(current));
return current is null ? TypedResults.NotFound() : TypedResults.Conflict(await ToDtoAsync(db, current, ct));
}
if (categoryChanged)
@@ -257,7 +271,7 @@ public static class ShoppingEndpoints
await TrackSuggestionAsync(db, suggestion, item, manualCategory: true, timeProvider, loggerFactory, ct, countUse: false);
}
var dto = ToDto(item);
var dto = await ToDtoAsync(db, item, ct);
await hub.Clients.Group(LiveHub.ShoppingGroup).SendAsync(ShoppingEvents.ItemChanged, dto, ct);
return TypedResults.Ok(dto);
}
@@ -386,6 +400,26 @@ public static class ShoppingEndpoints
private static ShoppingListDto ToDto(ShoppingList list, int openCount) =>
new(list.Id, list.Name, list.IsDefault, list.SortOrder, openCount);
internal static ShoppingItemDto ToDto(ShoppingItem item) => new(
item.Id, item.ListId, item.Name, item.Quantity, item.Category, item.IsDone, item.DoneAt, item.CreatedBy, item.CreatedAt, item.RowVersion);
internal static ShoppingItemDto ToDto(ShoppingItem item, IReadOnlyDictionary<Guid, string> names) => new(
item.Id, item.ListId, item.Name, item.Quantity, item.Category, item.IsDone, item.DoneAt, item.CreatedBy, item.CreatedAt, item.RowVersion,
item.CreatedBy is { } creator ? names.GetValueOrDefault(creator) : null);
internal static async Task<ShoppingItemDto> ToDtoAsync(AppDbContext db, ShoppingItem item, CancellationToken ct) =>
ToDto(item, await UserNamesAsync(db, [item.CreatedBy], ct));
/// <summary>Anzeigenamen der Ersteller (auch gelöschter Benutzer), für „hinzugefügt von …“.</summary>
private static async Task<Dictionary<Guid, string>> UserNamesAsync(AppDbContext db, IEnumerable<Guid?> userIds, CancellationToken ct)
{
var ids = userIds.OfType<Guid>().Distinct().ToList();
if (ids.Count == 0)
{
return [];
}
var users = await db.Users.IgnoreQueryFilters().AsNoTracking()
.Where(u => ids.Contains(u.Id))
.Select(u => new { u.Id, u.DisplayName, u.UserName })
.ToListAsync(ct);
return users.ToDictionary(u => u.Id, u => string.IsNullOrWhiteSpace(u.DisplayName) ? u.UserName ?? string.Empty : u.DisplayName);
}
}
@@ -15,7 +15,8 @@ public sealed record ShoppingItemDto(
DateTimeOffset? DoneAt,
Guid? CreatedBy,
DateTimeOffset CreatedAt,
byte[] Version);
byte[] Version,
string? CreatedByName = null);
public sealed record ShoppingSuggestionDto(string Name, string Category, int UseCount);
@@ -24,8 +25,18 @@ public sealed record CreateShoppingListRequest(Guid? Id, string Name);
public sealed record UpdateShoppingListRequest(string Name, bool IsDefault);
/// <summary>Neuer Eintrag aus der Schnellerfassung. <paramref name="Id"/> erzeugt der Client – wiederholtes Senden ist unschädlich.</summary>
public sealed record AddShoppingItemRequest(Guid Id, string Text, string? Category = null);
/// <summary>
/// Neuer Eintrag aus der Schnellerfassung. <paramref name="Id"/> erzeugt der Client – wiederholtes Senden ist unschädlich.
/// <paramref name="Confirm"/> bestätigt einen Eintrag, bei dem die Plausibilitätsprüfung für Kinder nachgefragt hat (409).
/// </summary>
public sealed record AddShoppingItemRequest(Guid Id, string Text, string? Category = null, bool Confirm = false);
/// <summary>Namen der Zusatzfelder in Fehlerantworten der Einkaufsliste.</summary>
public static class ShoppingProblemExtensions
{
/// <summary>409-Antwort mit diesem Flag: Eintrag wirkt unsinnig, erst nach Rückfrage (Confirm = true) speichern.</summary>
public const string NeedsConfirmation = "needsConfirmation";
}
/// <summary>
/// Teiländerung eines Eintrags. Nur gesetzte Felder werden geändert. Status (<see cref="IsDone"/>) wird zustandsbasiert gesetzt
@@ -6,11 +6,19 @@
@onclick="OnToggle" aria-label="@($"{Item.Name}{(Item.Quantity is null ? "" : $", {Item.Quantity}")} – {(Item.IsDone ? "wieder offen" : "abhaken")}")">
<MudIcon Icon="@(Item.IsDone ? Icons.Material.Filled.CheckCircle : Icons.Material.Outlined.Circle)"
Color="@(Item.IsDone ? Color.Success : Color.Default)" Class="fp-item-check" />
<span class="fp-item-name">@Item.Name</span>
@if (Item.Quantity is not null)
{
<span class="fp-item-quantity">@Item.Quantity</span>
}
<span class="fp-item-text">
<span class="fp-item-line">
<span class="fp-item-name">@Item.Name</span>
@if (Item.Quantity is not null)
{
<span class="fp-item-quantity">@Item.Quantity</span>
}
</span>
@if (!string.IsNullOrWhiteSpace(Item.CreatedByName))
{
<span class="fp-item-addedby">hinzugefügt von @Item.CreatedByName</span>
}
</span>
</button>
@if (OnCategory.HasDelegate && CanToggle && !Item.IsDone)
{
@@ -129,6 +129,16 @@ else
private Guid UserId => User?.Id ?? Guid.Empty;
/// <summary>Plausibilitätsprüfung für Kinder (dieselbe Regel wie in der Api).</summary>
private bool NeedsPlausibilityCheck => User is not null && ShoppingPlausibility.AppliesTo(User.Roles);
private async Task<bool> ConfirmSuspiciousAsync(string text) =>
await DialogService.ShowMessageBoxAsync(
"Wirklich hinzufügen?",
$"„{text.Trim()}“ sieht nicht nach einem Artikel aus. Soll es trotzdem auf die Einkaufsliste?",
yesText: "Ja, hinzufügen",
cancelText: "Nein") == true;
private bool CanWrite => User?.Can(Permission.ShoppingWrite) == true;
private bool CanManageLists => User?.Roles.Any(r => r is Roles.Admin or Roles.Family) == true;
@@ -140,6 +150,7 @@ else
{
Store.Changed += OnStoreChanged;
Store.Notice += OnNotice;
Store.ConfirmSuspicious = ConfirmSuspiciousAsync; // Rückfrage auch, wenn erst der Server den Eintrag beanstandet (409)
await Store.InitializeAsync();
}
@@ -180,16 +191,27 @@ else
return;
}
var confirmed = false;
if (NeedsPlausibilityCheck && ShoppingPlausibility.IsSuspicious(text))
{
if (!await ConfirmSuspiciousAsync(text))
{
return; // Eingabe bleibt stehen, damit das Kind sie korrigieren kann.
}
confirmed = true;
}
_newText = string.Empty;
_matches = [];
await Store.AddAsync(text, UserId);
await Store.AddAsync(text, UserId, User?.DisplayName, confirmed);
}
private async Task AddSuggestionAsync(ShoppingSuggestionDto suggestion)
{
_newText = string.Empty;
_matches = [];
await Store.AddAsync(suggestion.Name, UserId);
await Store.AddAsync(suggestion.Name, UserId, User?.DisplayName);
}
private void OnTextChanged()
@@ -253,6 +275,7 @@ else
{
Store.Changed -= OnStoreChanged;
Store.Notice -= OnNotice;
Store.ConfirmSuspicious = null;
foreach (var subscription in new[] { _swipe, _network })
{
if (subscription is null)
@@ -40,6 +40,10 @@ public sealed class ApiProblem
public string? Detail { get; set; }
/// <summary>409 der Einkaufsliste: Eintrag wirkt unsinnig und wird erst nach Rückfrage gespeichert.</summary>
[System.Text.Json.Serialization.JsonPropertyName(FamilyPlanner.Shared.Shopping.ShoppingProblemExtensions.NeedsConfirmation)]
public bool NeedsConfirmation { get; set; }
public Dictionary<string, string[]>? Errors { get; set; }
public string Message =>
@@ -67,9 +67,15 @@ internal static class ApiCall
using var response = await send();
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
var body = await response.Content.ReadAsStringAsync(ct);
if (TryReadConfirmation(body) is { } confirmation)
{
return ApiResult<T>.Fail(confirmation);
}
// 409 enthält bei Einträgen den aktuellen Serverstand.
var problem = new ApiProblem { Status = 409, Detail = "Der Eintrag wurde inzwischen geändert." };
var current = await TryReadAsync<T>(response, ct);
var current = TryDeserialize<T>(body);
return new ApiResult<T>(current, problem);
}
@@ -103,11 +109,26 @@ internal static class ApiCall
}
}
private static async Task<T?> TryReadAsync<T>(HttpResponseMessage response, CancellationToken ct)
private static readonly System.Text.Json.JsonSerializerOptions JsonOptions = new(System.Text.Json.JsonSerializerDefaults.Web);
/// <summary>Erkennt die 409-Antwort „Rückfrage nötig“ (Plausibilitätsprüfung für Kinder).</summary>
private static ApiProblem? TryReadConfirmation(string body)
{
var problem = TryDeserialize<ApiProblem>(body);
if (problem is not { NeedsConfirmation: true })
{
return null;
}
problem.Status = 409;
return problem;
}
private static T? TryDeserialize<T>(string body)
{
try
{
return await response.Content.ReadFromJsonAsync<T>(ct);
return System.Text.Json.JsonSerializer.Deserialize<T>(body, JsonOptions);
}
catch (Exception ex) when (ex is System.Text.Json.JsonException or NotSupportedException)
{
@@ -24,7 +24,8 @@ public sealed record ShoppingOp(
string? Text = null,
bool? IsDone = null,
string? Category = null,
byte[]? Version = null);
byte[]? Version = null,
bool Confirm = false);
/// <summary>
/// Zustand der Einkaufslisten im Client: Offline-Cache, optimistische Änderungen, Outbox und Echtzeit-Abgleich.
@@ -66,6 +67,12 @@ public sealed class ShoppingStore : IAsyncDisposable
/// <summary>Meldungen für den Benutzer (z. B. verworfene Änderung).</summary>
public event Action<string>? Notice;
/// <summary>
/// Fragt den Benutzer, ob ein vom Server als unsinnig eingestufter Eintrag (409 „Rückfrage nötig“) trotzdem gespeichert werden soll.
/// Ohne Handler wird der Eintrag verworfen.
/// </summary>
public Func<string, Task<bool>>? ConfirmSuspicious { get; set; }
public IReadOnlyList<ShoppingListDto> Lists { get; private set; } = [];
public Guid? SelectedListId { get; private set; }
@@ -164,7 +171,7 @@ public sealed class ShoppingStore : IAsyncDisposable
}
/// <summary>Schnellerfassung: „2x Milch“ → Eintrag „Milch“, Menge „2“.</summary>
public async Task<ShoppingItemDto?> AddAsync(string text, Guid currentUserId)
public async Task<ShoppingItemDto?> AddAsync(string text, Guid currentUserId, string? currentUserName = null, bool confirmed = false)
{
if (SelectedListId is not { } listId)
{
@@ -181,10 +188,10 @@ public sealed class ShoppingStore : IAsyncDisposable
var item = new ShoppingItemDto(
Guid.CreateVersion7(), listId, parsed.Name, parsed.Quantity,
learned?.Category ?? ShoppingCategories.Guess(parsed.Name),
IsDone: false, DoneAt: null, currentUserId, DateTimeOffset.UtcNow, Version: []);
IsDone: false, DoneAt: null, currentUserId, DateTimeOffset.UtcNow, Version: [], CreatedByName: currentUserName);
await RememberSuggestionAsync(item);
await EnqueueAsync(new ShoppingOp(Guid.NewGuid(), ShoppingOpType.Add, listId, item.Id, Text: text.Trim()), item);
await EnqueueAsync(new ShoppingOp(Guid.NewGuid(), ShoppingOpType.Add, listId, item.Id, Text: text.Trim(), Confirm: confirmed), item);
return item;
}
@@ -358,7 +365,23 @@ public sealed class ShoppingStore : IAsyncDisposable
{
case ShoppingOpType.Add:
{
var result = await _api.AddItemAsync(op.ListId, new AddShoppingItemRequest(op.ItemId, op.Text ?? string.Empty, op.Category));
var result = await _api.AddItemAsync(op.ListId, new AddShoppingItemRequest(op.ItemId, op.Text ?? string.Empty, op.Category, op.Confirm));
if (result.Problem is { Status: 409, NeedsConfirmation: true })
{
// Plausibilitätsprüfung (Kinder): nachfragen, bei „Ja“ mit Bestätigung erneut senden, sonst verwerfen.
if (ConfirmSuspicious is null || !await ConfirmSuspicious(op.Text ?? string.Empty))
{
return (false, null, true, null);
}
if (_outbox.Count > 0 && _outbox[0].OpId == op.OpId)
{
_outbox[0] = op with { Confirm = true }; // bei Netzfehler nicht erneut fragen
}
result = await _api.AddItemAsync(op.ListId, new AddShoppingItemRequest(op.ItemId, op.Text ?? string.Empty, op.Category, Confirm: true));
}
if (IsRetryable(result.Problem))
{
return (true, null, false, null);
+23
View File
@@ -237,17 +237,40 @@ body:has(.fp-app) .app-loading {
flex-shrink: 0;
}
.fp-item-text {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.fp-item-line {
display: flex;
align-items: center;
gap: 12px;
}
.fp-item-name {
flex: 1;
word-break: break-word;
}
.fp-item-addedby {
color: var(--mud-palette-text-secondary);
font-size: .75rem;
line-height: 1.2;
}
.fp-item-quantity {
color: var(--mud-palette-text-secondary);
font-size: .95rem;
white-space: nowrap;
}
.fp-item.is-done .fp-item-addedby {
color: var(--mud-palette-text-disabled);
}
.fp-item.is-done .fp-item-name,
.fp-item.is-done .fp-item-quantity {
text-decoration: line-through;