feat: show who added a shopping item, confirm implausible child entries
This commit is contained in:
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user