merge: Phase 11a small improvements

This commit is contained in:
Jannis committed 2026-10-10 19:27:06 +02:00
commit 2ec0d40447
38 files changed
+3758 -40

No files matched your search

+4 -2
View File
@@ -81,13 +81,14 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
|---|---|
| Identity ✅ | `AppUser` (DisplayName, Color, AvatarUrl, MustChangePassword, IsActive, ExpiresAt, PinHash, LastLoginAt, Soft Delete), `AppRole` (Admin, Familie, Kind, Gast), `UserAvatar` (Bild ≤ 1 MB) |
| Einstellungen ✅ | `FamilySettings` (FamilyName, TimeZoneId, MealPlanDays (7–10), MealTypes, TaskCategories, ChorePointsEnabled, ChildTasksRequireApproval, GuestsCanEditShoppingList, GuestChatEnabled) |
| Einkauf ✅ | `ShoppingList` (Name, IsDefault, SortOrder, Soft Delete), `ShoppingItem` (Client-Id, Name, Quantity, Category, IsDone, DoneAt, DoneBy, RowVersion, Soft Delete), `ShoppingSuggestion` (Name, Category, CategoryIsManual, UseCount, LastUsedAt) |
| Einkauf ✅ | `ShoppingList` (Name, IsDefault, SortOrder, Soft Delete), `ShoppingItem` (Client-Id, Name, Quantity, Category, IsDone, DoneAt, DoneBy, RowVersion, Soft Delete), `ShoppingSuggestion` (Name, Category, CategoryIsManual, UseCount, LastUsedAt). Die Items-DTOs liefern `CreatedByName` („hinzugefügt von …“); für Kinder prüft `ShoppingPlausibility` (Sonderzeichen/Ziffern, Zeichenwiederholung, Tastaturmuster, kleine Quatschliste) Neu-Einträge: verdächtige liefern 409 mit `needsConfirmation`, der Client fragt nach und sendet `Confirm = true` |
| Essen ✅ | `MealPlanEntry` (Date als `DateOnly`, MealType, DishName, Note, RecipeUrl, Ingredients, WishId, RowVersion, Soft Delete; eindeutig je Tag + Mahlzeit), `Dish` (Name, RecipeUrl, Ingredients, UseCount – für Autovervollständigung), `MealWish` (DishName, Note, Wunschtag/-mahlzeit, Status offen/übernommen/abgelehnt, Soft Delete) |
| Aufgaben ✅ | `TodoTask` (Title, Notes, DueDate + DueTime → DueAt, ReminderMinutesBefore → RemindAt, Priority, Status inkl. „wartet auf Bestätigung“, Category, IsPrivate, IsChore, Points, NeedsApproval, AssignedToFamily, RecurrenceRule + RecurrenceMode, SeriesId/SeriesStart/SeriesIndex, CompletedBy/At, ApprovedBy/At, RowVersion, Soft Delete), `TaskAssignment`, `TaskChecklistItem`, `TaskHistoryEntry` (Verlauf), `PointsLedgerEntry` (Punktebuch). Kategorien kommen aus `FamilySettings.TaskCategories`; „wer/wann/genehmigt“ steht an der Aufgabe und im Verlauf (ADR-0014) |
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt) – ADR-0015/0016 |
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt) – ADR-0020 |
| Chat ✅ | `ChatChannel` (Family / Direct), `ChatMember` (LastReadAt), `ChatMessage` (Text, HasImage), `ChatAttachment`, `ChatReaction` |
| Lernen ✅ (10a) | `LearningSet` (Titel, Fach Mathe/Deutsch, Klassenstufe 1–6, Zielkind, Fälligkeit, Status, Modus Übung/Prüfung, MaxAttempts, TimeLimitMinutes, ShowSolutionsAfterSubmit, ShuffleItems, TotalPoints, GradeThresholds, Source Manual/Ai + AiPrompt/AiProvider/AiModel (von 10b für KI-Entwürfe gesetzt, nie für Kinder sichtbar), RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice, Paper vorgesehen, CorrectAnswer, NumberTolerance, Options, Hint, Explanation, Difficulty, Points, RowVersion, Soft Delete), `LearningAttempt` (Nummer, Status, StartedAt, DeadlineAt, SubmittedAt, AutoSubmitted, ItemOrder, Ergebnis/Note, RowVersion), `LearningAnswer` (Antwort, Verdict Open/Correct/Partial/Wrong, Grader Auto/Ai/Parent, AwardedPoints, Reason, AiConfidence, PhotoPurgedAt), `LearningPhoto` ✅ (10c: Foto der Papierlösung je Versuch und Aufgabe, Bytes in der Datenbank); `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
| Dashboard ✅ | `DashboardNote` (Title, Text, Soft Delete; Freitext der Info-Kachel, wird nur als Text dargestellt) |
| Betrieb | `AuditLogEntry` ✅ (ActorName, Action, TargetType/Id/Name, Details, CreatedAt), `PushSubscription` ✅ (UserId, Endpoint + Hash, P256dh, Auth, LastSuccessAt, FailureCount) |
## 4. Rollen- und Berechtigungsmodell
@@ -100,6 +101,7 @@ oder **None (–)**. Die Api registriert je Permission eine Policy `Permission:<
| Permission | Admin | Familie | Kind | Gast | Bedeutung von R |
|---|---|---|---|---|---|
| `DashboardView` | F | F | F | R | Gast: eingeschränkte Kacheln |
| `DashboardNotesEdit` | F | F | – | – | Info-Kachel (Freitext-Notizen) pflegen; ansehen dürfen alle mit `DashboardView` |
| `ShoppingRead` | F | F | F | F | |
| `ShoppingWrite` | F | F | F | – ¹ | |
| `ShoppingDelete` | F | F | R | – | nur eigene Einträge |
@@ -157,7 +159,7 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
| Chat ✅ | `GET /api/chat/channels`, `POST /api/chat/direct`, `GET /api/chat/channels/{id}/messages?before=&take=`, `POST …/messages`, `POST …/images` (Multipart), `POST …/read`, `DELETE /api/chat/messages/{id}`, `PUT /api/chat/messages/{id}/reactions`, `GET /api/chat/attachments/{id}` (ADR-0019) |
| KI ✅ | Admin: `GET/POST /api/admin/ai/providers`, `PUT/DELETE …/{id}`, `POST …/{id}/test`, `GET /api/admin/ai/usage`; Eltern: `GET /api/ai/status` (ADR-0020) |
| Lernen ✅ (10a) | Eltern (`LearningManage`): `GET/POST /api/learning/sets`, `GET/PUT/DELETE …/sets/{id}`, `POST …/sets/{id}/assign`, `POST …/unassign`, `POST …/sets/{id}/items`, `PUT/DELETE …/items/{itemId}`, `POST …/items/order`, `GET /api/learning/review`, `GET /api/learning/attempts/{id}`, `PUT …/attempts/{id}/answers/{itemId}/review`, `POST …/attempts/{id}/confirm`, `POST …/attempts/{id}/reopen`; Kinder (`LearningSolve`): `GET /api/learning/my`, `POST …/my/{setId}/attempts`, `GET …/my/attempts/{id}`, `PUT …/my/attempts/{id}/answers/{itemId}`, `POST …/my/attempts/{id}/submit`; Fotos (10c): `POST/DELETE /api/learning/my/attempts/{id}/items/{itemId}/photo` (Kind), `GET /api/learning/attempts/{id}/items/{itemId}/photo` (Eltern nach Abgabe, Kind; sonst 404), `POST /api/learning/attempts/{id}/ai-grade` (`LearningManage` + `AiUse`, Foto-KI-Prüfung) (ADR-0021) |
| Dashboard ✅ | `GET /api/dashboard` (alle Kacheln der Rolle in einem Request, ADR-0018) |
| Dashboard ✅ | `GET /api/dashboard` (alle Kacheln der Rolle in einem Request, ADR-0018; inkl. Info-Kachel `Notes`), `POST /api/dashboard/notes`, `PUT/DELETE /api/dashboard/notes/{id}` (`DashboardNotesEdit`, Titel ≤ 80, Text ≤ 1000 Zeichen, höchstens 20 Notizen) |
| Hubs | `/hubs/live` (Live-Updates: Einkauf ✅, Essenplan ✅, Aufgaben ✅, Termine ✅, Chat ✅ inkl. Hub-Methode `Typing`, Lernen ✅ (`LearningChanged` ohne Inhalte); das Dashboard lädt bei diesen Ereignissen neu) |
| Betrieb | `/health`, `/alive` |
+6 -3
View File
@@ -140,6 +140,11 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- [ ] Tests vervollständigen (Policies, ICS, Wiederholungen, Integration)
- [ ] Barrierefreiheit (WCAG 2.1 AA), Performance (AOT/Trimming prüfen, Lazy Loading)
## Phase 11a – Kleine Verbesserungen
- [x] Einkaufsliste: kleiner Hinweis „hinzugefügt von …“ unter dem Eintrag (`ShoppingItemDto.CreatedByName`); Plausibilitätsprüfung für Kinder – offensichtlicher Unsinn wird nur nach Rückfrage gespeichert (Regelliste in `Domain/Shopping/ShoppingPlausibility`, ohne KI; 409 mit `needsConfirmation`, Wiederholung mit `Confirm = true`)
- [x] Dashboard: allgemeine Info-Kachel für Freitext-Notizen (`DashboardNote`, Recht `DashboardNotesEdit` für Eltern, alle sehen sie, nur Text) – Migration `Phase11aDashboardNotes`
- [x] Oberfläche: Klick auf die eigenen Initialen öffnet ein Menü (Passwort ändern, Meine Einstellungen mit Theme-Wahl, Benachrichtigungen, Abmelden)
## Phase 12 – Deployment
- [ ] Finale `docker-compose.yml` (inkl. optionalem Caddy), `.env.example`
- [ ] Backup-/Restore-Skripte
@@ -166,6 +171,4 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- Lernen: tolerantere Antwort-Auswertung für Zahlen mit Einheiten und Schreibweisen (z. B. „1€ 30 Cent“ = „1,30“, „2 Euro 50“); unklare Fälle optional zur Gegenprüfung an die KI (Purpose und Opt-in aus ADR-0020), Ergebnis bleibt Vorschlag, Eltern bestätigen
- Lernen: Kind sieht die Note auch auf dem Lernzettel selbst (Kopfbereich des Blatts/Ergebnisansicht), nicht nur in der Liste
- Lernen: Übungsblätter automatisch erzeugen (Zeitplan je Kind, z. B. täglich/wöchentlich 10 neue Aufgaben, Schwierigkeit steigt schrittweise; Hintergrunddienst, nur Eltern konfigurieren, Tageslimit der KI beachten, Entwürfe bleiben prüfbar oder werden nach Eltern-Einstellung direkt zugewiesen)
- Einkaufsliste: kleiner Hinweis „hinzugefügt von …“ unter dem Eintrag; Plausibilitätsprüfung für Kinder – offensichtlicher Unsinn wird nur nach Rückfrage gespeichert (Regelliste/Wörterbuch, optional KI)
- Dashboard: allgemeine Kachel für Freitext-Infos (Eltern pflegen, z. B. „Wlan-Passwort“, „Ferien bis …“)
- Oberfläche: Klick auf die eigenen Initialen öffnet ein Fenster/Menü (Passwort ändern, Meine Einstellungen, Abmelden)
@@ -6,6 +6,7 @@ using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Dashboard;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Domain.Meals;
using FamilyPlanner.Domain.Tasks;
@@ -28,6 +29,11 @@ public static class DashboardEndpoints
public static IEndpointRouteBuilder MapDashboardEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/dashboard", GetAsync).WithTags("Dashboard").RequirePermission(Permission.DashboardView);
var notes = app.MapGroup("/api/dashboard/notes").WithTags("Dashboard");
notes.MapPost("", CreateNoteAsync).RequirePermission(Permission.DashboardNotesEdit).Validate<SaveDashboardNoteRequest>();
notes.MapPut("/{id:guid}", UpdateNoteAsync).RequirePermission(Permission.DashboardNotesEdit).Validate<SaveDashboardNoteRequest>();
notes.MapDelete("/{id:guid}", DeleteNoteAsync).RequirePermission(Permission.DashboardNotesEdit);
return app;
}
@@ -61,7 +67,58 @@ public static class DashboardEndpoints
var learning = await LoadLearningAsync(user, db, settingsProvider, today, ct);
return TypedResults.Ok(new DashboardDto(today, meals, events, shopping, myTasks, familyTasks, chat, learning));
var notes = await LoadNotesAsync(db, Allowed(Permission.DashboardNotesEdit), ct);
return TypedResults.Ok(new DashboardDto(today, meals, events, shopping, myTasks, familyTasks, chat, learning, notes));
}
private static async Task<DashboardNotesDto> LoadNotesAsync(AppDbContext db, bool canEdit, CancellationToken ct)
{
var notes = await db.DashboardNotes.AsNoTracking().OrderBy(n => n.CreatedAt).ThenBy(n => n.Id).ToListAsync(ct);
return new DashboardNotesDto(notes.Select(ToDto).ToList(), canEdit);
}
private static DashboardNoteDto ToDto(DashboardNote note) => new(note.Id, note.Title, note.Text);
internal static async Task<Results<Created<DashboardNoteDto>, ProblemHttpResult>> CreateNoteAsync(
SaveDashboardNoteRequest request, AppDbContext db, CancellationToken ct)
{
if (await db.DashboardNotes.CountAsync(ct) >= DashboardNote.MaxCount)
{
return EndpointHelpers.Conflict($"Es sind höchstens {DashboardNote.MaxCount} Notizen möglich.");
}
var note = new DashboardNote { Title = request.Title.Trim(), Text = request.Text.Trim() };
db.DashboardNotes.Add(note);
await db.SaveChangesAsync(ct);
return TypedResults.Created($"/api/dashboard/notes/{note.Id}", ToDto(note));
}
internal static async Task<Results<Ok<DashboardNoteDto>, NotFound>> UpdateNoteAsync(
Guid id, SaveDashboardNoteRequest request, AppDbContext db, CancellationToken ct)
{
var note = await db.DashboardNotes.SingleOrDefaultAsync(n => n.Id == id, ct);
if (note is null)
{
return TypedResults.NotFound();
}
note.Title = request.Title.Trim();
note.Text = request.Text.Trim();
await db.SaveChangesAsync(ct);
return TypedResults.Ok(ToDto(note));
}
internal static async Task<NoContent> DeleteNoteAsync(Guid id, AppDbContext db, CancellationToken ct)
{
var note = await db.DashboardNotes.SingleOrDefaultAsync(n => n.Id == id, ct);
if (note is not null)
{
db.DashboardNotes.Remove(note); // Soft Delete über den Interceptor
await db.SaveChangesAsync(ct);
}
return TypedResults.NoContent();
}
private const int LearningTileItems = 5;
@@ -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);
}
}
@@ -4,6 +4,7 @@ namespace FamilyPlanner.Domain.Authorization;
public enum Permission
{
DashboardView,
DashboardNotesEdit,
ShoppingRead,
ShoppingWrite,
ShoppingDelete,
@@ -55,7 +56,8 @@ public static class PermissionMatrix
private static readonly Dictionary<Permission, AccessLevel[]> Matrix = new()
{
[Permission.DashboardView] = [F, F, F, R],
[Permission.ShoppingRead] = [F, F, F, F],
[Permission.DashboardNotesEdit] = [F, F, N, N], // Info-Kachel pflegen: nur Eltern
[Permission.ShoppingRead] =[F, F, F, F],
[Permission.ShoppingWrite] = [F, F, F, N], // Gast: konfigurierbar
[Permission.ShoppingDelete] = [F, F, R, N], // Kind: nur eigene
[Permission.MealPlanRead] = [F, F, F, F],
@@ -0,0 +1,23 @@
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Dashboard;
/// <summary>Freitext-Info für die Info-Kachel der Startseite (z. B. „Wlan-Passwort“). Wird nur als Text angezeigt.</summary>
public sealed class DashboardNote : AuditableEntity, ISoftDeletable
{
public const int TitleMaxLength = 80;
public const int TextMaxLength = 1000;
/// <summary>Höchstzahl aktiver Notizen, damit die Kachel übersichtlich bleibt.</summary>
public const int MaxCount = 20;
public string Title { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
}
@@ -0,0 +1,125 @@
namespace FamilyPlanner.Domain.Shopping;
/// <summary>
/// Einfache, regelbasierte Plausibilitätsprüfung für Einkaufseinträge von Kindern (ohne KI): offensichtlicher Unsinn wird
/// erst nach Rückfrage gespeichert. Die Regeln sind bewusst grob – im Zweifel lieber nicht nachfragen.
/// </summary>
public static class ShoppingPlausibility
{
/// <summary>Tastaturreihen (deutsches Layout); aufeinanderfolgende Zeichen davon gelten als „Hämmern“ auf der Tastatur.</summary>
private static readonly string[] KeyboardRows = ["qwertzuiop", "asdfghjkl", "yxcvbnm", "qwertyuiop", "zxcvbnm"];
/// <summary>Fenster, die in echten Wörtern vorkommen (z. B. „Wertstoffsack“) und deshalb nicht als Tastaturmuster zählen.</summary>
private static readonly HashSet<string> IgnoredKeyboardWindows = ["wert"];
private const int KeyboardWindow = 4;
private static readonly HashSet<string> Nonsense = new(StringComparer.Ordinal)
{
"asdf", "asdfg", "qwertz", "qwerty", "blabla", "bla", "blub", "lol", "haha", "hahaha", "hihi", "hehe", "lalala",
"abc", "xyz", "test", "egal", "nix", "nichts", "irgendwas", "keine ahnung", "kacke", "kaka", "popo", "pups",
"furz", "fuck", "scheiße", "scheisse", "penis", "sex", "ja", "nein", "ok", "hallo", "hi", "doof", "dumm",
};
/// <summary>Die Prüfung gilt nur für Kinder (Rolle Kind ohne Eltern-Rolle).</summary>
public static bool AppliesTo(IEnumerable<string> roles)
{
var list = roles.ToList();
return list.Contains(Roles.Child) && !list.Contains(Roles.Admin) && !list.Contains(Roles.Family);
}
/// <summary>Ist der eingegebene Text (ggf. mit Menge, z. B. „2x Milch“) offensichtlich kein Artikel?</summary>
public static bool IsSuspicious(string? text)
{
var parsed = QuantityParser.Parse(text ?? string.Empty);
var name = parsed.Name.Trim();
if (name.Length == 0)
{
return false; // Leere Eingaben fängt die Validierung ab.
}
var letters = new string(name.Where(char.IsLetter).ToArray()).ToLowerInvariant();
// 1. Nur Sonderzeichen und/oder Ziffern.
if (letters.Length == 0)
{
return true;
}
// 2. Kleine Quatschliste (ganzer Eintrag, ohne Satzzeichen).
var normalized = string.Join(' ', new string(name.Select(c => char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : ' ').ToArray())
.Split(' ', StringSplitOptions.RemoveEmptyEntries));
if (Nonsense.Contains(normalized))
{
return true;
}
// 3. Zeichenwiederholung: „aaaa“, „hahaha“, „lolo lolo“.
if (HasRepeatedCharacter(letters, 4) || IsRepeatedUnit(letters))
{
return true;
}
// 4. Tastaturmuster: „asdf“, „qwertz“, „hjkl“, auch rückwärts.
if (HasKeyboardPattern(letters))
{
return true;
}
// 5. Längere Buchstabenfolge ohne einen einzigen Vokal.
return letters.Length >= 5 && letters.All(c => !"aeiouäöüy".Contains(c));
}
private static bool HasRepeatedCharacter(string value, int count)
{
var run = 1;
for (var i = 1; i < value.Length; i++)
{
run = value[i] == value[i - 1] ? run + 1 : 1;
if (run >= count)
{
return true;
}
}
return false;
}
/// <summary>Der ganze Text besteht aus einer 2–3 Zeichen langen Einheit, die mindestens dreimal wiederholt wird.</summary>
private static bool IsRepeatedUnit(string value)
{
for (var unit = 2; unit <= 3; unit++)
{
if (value.Length >= unit * 3 && value.Length % unit == 0)
{
var first = value[..unit];
if (Enumerable.Range(0, value.Length / unit).All(i => string.CompareOrdinal(value, i * unit, first, 0, unit) == 0))
{
return true;
}
}
}
return false;
}
private static bool HasKeyboardPattern(string letters)
{
for (var i = 0; i + KeyboardWindow <= letters.Length; i++)
{
var window = letters.Substring(i, KeyboardWindow);
if (IgnoredKeyboardWindows.Contains(window))
{
continue;
}
var reversed = new string(window.Reverse().ToArray());
if (KeyboardRows.Any(row => row.Contains(window, StringComparison.Ordinal) || row.Contains(reversed, StringComparison.Ordinal)))
{
return true;
}
}
return false;
}
}
@@ -2,6 +2,7 @@ using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Domain.Dashboard;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Domain.Meals;
using FamilyPlanner.Domain.Push;
@@ -25,6 +26,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<UserAvatar> UserAvatars => Set<UserAvatar>();
public DbSet<DashboardNote> DashboardNotes => Set<DashboardNote>();
public DbSet<ShoppingList> ShoppingLists => Set<ShoppingList>();
public DbSet<ShoppingItem> ShoppingItems => Set<ShoppingItem>();
@@ -0,0 +1,15 @@
using FamilyPlanner.Domain.Dashboard;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace FamilyPlanner.Infrastructure.Persistence.Configurations;
internal sealed class DashboardNoteConfiguration : IEntityTypeConfiguration<DashboardNote>
{
public void Configure(EntityTypeBuilder<DashboardNote> builder)
{
builder.ToTable("DashboardNotes");
builder.Property(n => n.Title).HasMaxLength(DashboardNote.TitleMaxLength).IsRequired();
builder.Property(n => n.Text).HasMaxLength(DashboardNote.TextMaxLength).IsRequired();
}
}
@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase11aDashboardNotes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DashboardNotes",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Title = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
Text = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DashboardNotes", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_DashboardNotes_IsDeleted",
table: "DashboardNotes",
column: "IsDeleted");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DashboardNotes");
}
}
}
@@ -682,6 +682,50 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.ToTable("ChatReactions", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Dashboard.DashboardNote", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.ToTable("DashboardNotes", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAnswer", b =>
{
b.Property<Guid>("Id")
@@ -1,5 +1,7 @@
using FamilyPlanner.Domain.Dashboard;
using FamilyPlanner.Domain.Tasks;
using FamilyPlanner.Shared.Calendar;
using FluentValidation;
namespace FamilyPlanner.Shared.Dashboard;
@@ -45,6 +47,26 @@ public sealed record DashboardLearningItemDto(
/// </summary>
public sealed record DashboardLearningDto(int OpenCount, int ToReviewCount, bool ForParents, IReadOnlyList<DashboardLearningItemDto> Items);
/// <summary>Freitext-Info der Info-Kachel (nur als Text darstellen, nie als HTML).</summary>
public sealed record DashboardNoteDto(Guid Id, string Title, string Text);
/// <summary>Info-Kachel: alle Notizen und ob der Benutzer sie pflegen darf (Eltern).</summary>
public sealed record DashboardNotesDto(IReadOnlyList<DashboardNoteDto> Items, bool CanEdit);
/// <summary>Neue oder geänderte Notiz; die Id kann vom Client stammen.</summary>
public sealed record SaveDashboardNoteRequest(string Title, string Text);
public sealed class SaveDashboardNoteRequestValidator : AbstractValidator<SaveDashboardNoteRequest>
{
public SaveDashboardNoteRequestValidator()
{
RuleFor(r => r.Title).NotEmpty().WithMessage("Bitte einen Titel eingeben.")
.MaximumLength(DashboardNote.TitleMaxLength).WithMessage($"Der Titel darf höchstens {DashboardNote.TitleMaxLength} Zeichen haben.");
RuleFor(r => r.Text).NotEmpty().WithMessage("Bitte einen Text eingeben.")
.MaximumLength(DashboardNote.TextMaxLength).WithMessage($"Der Text darf höchstens {DashboardNote.TextMaxLength} Zeichen haben.");
}
}
/// <summary>
/// Alle Kacheln der Startseite in einem Request. Eine Kachel ist <c>null</c>, wenn die Rolle sie nicht sehen darf
/// (z. B. Gäste ohne Chat, Kinder ohne Familienaufgaben). Die Berechtigungen werden serverseitig geprüft.
@@ -57,4 +79,5 @@ public sealed record DashboardDto(
DashboardMyTasksDto? MyTasks,
DashboardFamilyTasksDto? FamilyTasks,
DashboardChatDto? Chat,
DashboardLearningDto? Learning = null);
DashboardLearningDto? Learning = null,
DashboardNotesDto? Notes = null);
@@ -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
@@ -0,0 +1,62 @@
@using FamilyPlanner.Domain.Dashboard
@using FamilyPlanner.Shared.Dashboard
@inject IDashboardApi Api
<MudDialog>
<DialogContent>
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" Class="mb-3" role="alert">@_error</MudAlert>
}
<MudTextField @bind-Value="_title" Label="Titel" Variant="Variant.Outlined" Margin="Margin.Dense" MaxLength="DashboardNote.TitleMaxLength"
Class="fp-note-title" Error="@(_errors.ContainsKey("Title"))" ErrorText="@_errors.GetValueOrDefault("Title")" />
<MudTextField @bind-Value="_text" Label="Text" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="4" Sizing="InputSizing.Auto" MaxLength="DashboardNote.TextMaxLength"
Class="fp-note-text mt-3" Counter="DashboardNote.TextMaxLength" Error="@(_errors.ContainsKey("Text"))" ErrorText="@_errors.GetValueOrDefault("Text")" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => Dialog?.Cancel())">Abbrechen</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="_busy" Class="fp-note-save">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
/// <summary>Zu bearbeitende Notiz; <c>null</c> legt eine neue an.</summary>
[Parameter]
public DashboardNoteDto? Note { get; set; }
private string _title = string.Empty;
private string _text = string.Empty;
private Dictionary<string, string> _errors = new(StringComparer.OrdinalIgnoreCase);
private string? _error;
private bool _busy;
protected override void OnInitialized() => (_title, _text) = (Note?.Title ?? string.Empty, Note?.Text ?? string.Empty);
private async Task SaveAsync()
{
_error = null;
var request = new SaveDashboardNoteRequest(_title.Trim(), _text.Trim());
var validation = new SaveDashboardNoteRequestValidator().Validate(request);
_errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => string.Join(" ", g.Select(e => e.ErrorMessage)), StringComparer.OrdinalIgnoreCase);
if (!validation.IsValid)
{
return;
}
_busy = true;
var result = Note is null ? await Api.CreateNoteAsync(request) : await Api.UpdateNoteAsync(Note.Id, request);
_busy = false;
if (result.Problem is { } problem)
{
_error = problem.Message;
return;
}
Dialog?.Close(DialogResult.Ok(result.Value));
}
}
@@ -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)
{
@@ -16,6 +16,9 @@
<MudText Typo="Typo.caption">@string.Join(", ", RolesOf(auth.User))</MudText>
</div>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Settings" OnClick="@(() => Navigation.NavigateTo("konto/einstellungen"))">
Meine Einstellungen
</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Password" OnClick="@(() => Navigation.NavigateTo("konto/passwort"))">
Passwort ändern
</MudMenuItem>
@@ -0,0 +1,26 @@
@page "/konto/einstellungen"
@inject ThemeState Theme
<PageTitle>Meine Einstellungen – Familienplaner</PageTitle>
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-4">Meine Einstellungen</MudText>
<MudPaper Outlined="true" Class="pa-4 fp-account-settings" Style="max-width: 640px">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Darstellung</MudText>
<MudRadioGroup T="ThemePreference" Value="Theme.Preference" ValueChanged="OnThemeChangedAsync" Class="fp-theme-choice">
<MudRadio T="ThemePreference" Value="ThemePreference.System" Color="Color.Primary">Wie das Gerät</MudRadio>
<MudRadio T="ThemePreference" Value="ThemePreference.Light" Color="Color.Primary">Hell</MudRadio>
<MudRadio T="ThemePreference" Value="ThemePreference.Dark" Color="Color.Primary">Dunkel</MudRadio>
</MudRadioGroup>
<MudDivider Class="my-4" />
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Benachrichtigungen</MudText>
<MudText Typo="Typo.body2" Class="mb-2">Erinnerungen auf diesem Gerät ein- oder ausschalten.</MudText>
<MudButton Href="konto/benachrichtigungen" Variant="Variant.Outlined" Color="Color.Primary" Class="fp-settings-notifications"
StartIcon="@Icons.Material.Filled.Notifications">Benachrichtigungen einstellen</MudButton>
</MudPaper>
@code {
private Task OnThemeChangedAsync(ThemePreference preference) => Theme.SetPreferenceAsync(preference);
}
+95 -1
View File
@@ -7,6 +7,7 @@
@inject IShoppingApi ShoppingApi
@inject ILiveConnection Live
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject TimeProvider TimeProvider
<PageTitle>Übersicht – Familienplaner</PageTitle>
@@ -284,6 +285,52 @@ else
</MudCard>
</MudItem>
}
@if (_data.Notes is { } notes && (notes.Items.Count > 0 || notes.CanEdit))
{
<MudItem xs="12" sm="6" lg="4">
<MudCard Outlined="true" Class="fp-tile fp-dash-notes">
<MudCardHeader>
<CardHeaderAvatar><MudIcon Icon="@Icons.Material.Filled.Info" Color="Color.Primary" /></CardHeaderAvatar>
<CardHeaderContent><MudText Typo="Typo.h6" HtmlTag="h2">Infos</MudText></CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (notes.Items.Count == 0)
{
<MudText Typo="Typo.body2" Class="fp-dash-empty">Noch keine Infos – z. B. Wlan-Passwort oder Ferientermine.</MudText>
}
else
{
<ul class="fp-dash-list fp-dash-notes-list">
@foreach (var note in notes.Items)
{
<li class="fp-dash-note">
<span class="fp-dash-label fp-dash-note-title">@note.Title</span>
<span class="fp-dash-main fp-dash-note-text">@note.Text</span>
@if (notes.CanEdit)
{
<span class="fp-dash-note-actions">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => EditNoteAsync(note))"
aria-label="@($"{note.Title} bearbeiten")" Class="fp-note-edit" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" OnClick="@(() => DeleteNoteAsync(note))"
aria-label="@($"{note.Title} löschen")" Class="fp-note-delete" />
</span>
}
</li>
}
</ul>
}
</MudCardContent>
@if (notes.CanEdit)
{
<MudCardActions>
<MudButton Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => EditNoteAsync(null))"
Class="fp-note-add">Info hinzufügen</MudButton>
</MudCardActions>
}
</MudCard>
</MudItem>
}
</MudGrid>
}
@@ -403,6 +450,36 @@ else
private Task OnChatRead(Guid _, Guid __, DateTimeOffset ___) => ScheduleRefreshAsync();
private async Task EditNoteAsync(DashboardNoteDto? note)
{
var dialog = await DialogService.ShowAsync<DashboardNoteDialog>(
note is null ? "Info hinzufügen" : "Info bearbeiten",
new DialogParameters<DashboardNoteDialog> { { d => d.Note, note } },
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true });
if (await dialog.Result is { Canceled: false })
{
await RefreshAsync();
}
}
private async Task DeleteNoteAsync(DashboardNoteDto note)
{
var confirmed = await DialogService.ShowMessageBoxAsync("Info löschen?", $"„{note.Title}“ wird für alle entfernt.", yesText: "Löschen", cancelText: "Abbrechen");
if (confirmed != true)
{
return;
}
var result = await DashboardApi.DeleteNoteAsync(note.Id);
if (!result.Succeeded)
{
Snackbar.Add(result.Problem?.Message ?? "Die Info konnte nicht gelöscht werden.", Severity.Warning);
return;
}
await RefreshAsync();
}
private Task OnQuickAddKeyAsync(KeyboardEventArgs args) => args.Key == "Enter" ? QuickAddAsync() : Task.CompletedTask;
private async Task QuickAddAsync()
@@ -416,7 +493,24 @@ else
_adding = true;
try
{
var result = await ShoppingApi.AddItemAsync(listId, new FamilyPlanner.Shared.Shopping.AddShoppingItemRequest(Guid.CreateVersion7(), text));
var request = new FamilyPlanner.Shared.Shopping.AddShoppingItemRequest(Guid.CreateVersion7(), text);
var result = await ShoppingApi.AddItemAsync(listId, request);
if (result.Problem is { Status: 409, NeedsConfirmation: true })
{
// Plausibilitätsprüfung für Kinder: nachfragen und mit Bestätigung erneut senden.
var confirmed = await DialogService.ShowMessageBoxAsync(
"Wirklich hinzufügen?",
$"„{text}“ sieht nicht nach einem Artikel aus. Soll es trotzdem auf die Einkaufsliste?",
yesText: "Ja, hinzufügen",
cancelText: "Nein");
if (confirmed != true)
{
return;
}
result = await ShoppingApi.AddItemAsync(listId, request with { Confirm = true });
}
if (result.Succeeded)
{
_quickText = null;
@@ -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 =>
@@ -1,3 +1,4 @@
using System.Net.Http.Json;
using FamilyPlanner.Shared.Dashboard;
namespace FamilyPlanner.Web.Client.Services;
@@ -5,10 +6,27 @@ namespace FamilyPlanner.Web.Client.Services;
public interface IDashboardApi
{
Task<ApiResult<DashboardDto>> GetAsync(CancellationToken ct = default);
Task<ApiResult<DashboardNoteDto>> CreateNoteAsync(SaveDashboardNoteRequest request, CancellationToken ct = default);
Task<ApiResult<DashboardNoteDto>> UpdateNoteAsync(Guid id, SaveDashboardNoteRequest request, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteNoteAsync(Guid id, CancellationToken ct = default);
}
public sealed class DashboardApi(HttpClient http) : IDashboardApi
{
private const string NotesBase = "api/dashboard/notes";
public Task<ApiResult<DashboardDto>> GetAsync(CancellationToken ct = default) =>
ApiCall.SendAsync<DashboardDto>(() => http.GetAsync("api/dashboard", ct), ct);
public Task<ApiResult<DashboardNoteDto>> CreateNoteAsync(SaveDashboardNoteRequest request, CancellationToken ct = default) =>
ApiCall.SendAsync<DashboardNoteDto>(() => http.PostAsJsonAsync(NotesBase, request, ct), ct);
public Task<ApiResult<DashboardNoteDto>> UpdateNoteAsync(Guid id, SaveDashboardNoteRequest request, CancellationToken ct = default) =>
ApiCall.SendAsync<DashboardNoteDto>(() => http.PutAsJsonAsync($"{NotesBase}/{id}", request, ct), ct);
public Task<ApiResult<bool>> DeleteNoteAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{NotesBase}/{id}", ct), ct);
}
@@ -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);
@@ -45,4 +45,12 @@ public sealed class ThemeState(IJSRuntime js)
await js.InvokeVoidAsync("familyPlanner.theme.save", Preference.ToString());
Changed?.Invoke();
}
/// <summary>Setzt die Wahl ausdrücklich (System/Hell/Dunkel) und speichert sie lokal.</summary>
public async Task SetPreferenceAsync(ThemePreference preference)
{
Preference = preference;
await js.InvokeVoidAsync("familyPlanner.theme.save", Preference.ToString());
Changed?.Invoke();
}
}
+32
View File
@@ -189,6 +189,15 @@ body:has(.fp-app) .app-loading {
color: var(--mud-palette-text-secondary);
}
.fp-dash-note-text {
white-space: pre-wrap;
}
.fp-dash-note-actions {
grid-column: 2;
justify-self: end;
}
/* Einkaufsliste */
.fp-items {
list-style: none;
@@ -237,17 +246,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;
@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Http.Json;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Dashboard;
using FamilyPlanner.Shared.Dashboard;
namespace FamilyPlanner.IntegrationTests;
/// <summary>Info-Kachel der Startseite: alle sehen die Notizen, nur Eltern pflegen sie (Permission.DashboardNotesEdit).</summary>
[Collection(SettingsCollection.Name)]
public sealed class DashboardNotesTests(AppFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static async Task<DashboardNotesDto> NotesAsync(HttpClient client) =>
(await client.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!.Notes!;
[Fact]
public async Task Parents_manage_notes_and_everyone_sees_them_as_text()
{
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
using var _f = family;
using var _c = child;
using var _g = guest;
var title = $"Wlan {Guid.NewGuid():N}";
var created = await family.PostAsJsonAsync("/api/dashboard/notes", new SaveDashboardNoteRequest(title, "<b>geheim</b>\nZeile 2"), Ct);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var note = (await created.Content.ReadFromJsonAsync<DashboardNoteDto>(Ct))!;
foreach (var client in new[] { family, child, guest })
{
var seen = Assert.Single((await NotesAsync(client)).Items, n => n.Id == note.Id);
Assert.Equal((title, "<b>geheim</b>\nZeile 2"), (seen.Title, seen.Text));
}
Assert.True((await NotesAsync(family)).CanEdit);
Assert.False((await NotesAsync(child)).CanEdit);
Assert.False((await NotesAsync(guest)).CanEdit);
var updated = await family.PutAsJsonAsync($"/api/dashboard/notes/{note.Id}", new SaveDashboardNoteRequest(title, "neu"), Ct);
Assert.Equal("neu", (await updated.Content.ReadFromJsonAsync<DashboardNoteDto>(Ct))!.Text);
Assert.Equal(HttpStatusCode.NoContent, (await family.DeleteAsync($"/api/dashboard/notes/{note.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await family.DeleteAsync($"/api/dashboard/notes/{note.Id}", Ct)).StatusCode); // idempotent
Assert.DoesNotContain((await NotesAsync(child)).Items, n => n.Id == note.Id);
Assert.Equal(HttpStatusCode.NotFound, (await family.PutAsJsonAsync($"/api/dashboard/notes/{note.Id}", new SaveDashboardNoteRequest(title, "x"), Ct)).StatusCode);
}
[Fact]
public async Task Children_and_guests_cannot_change_notes()
{
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
using var _f = family;
using var _c = child;
using var _g = guest;
var note = (await (await family.PostAsJsonAsync("/api/dashboard/notes", new SaveDashboardNoteRequest("Schutz", "Text"), Ct))
.Content.ReadFromJsonAsync<DashboardNoteDto>(Ct))!;
foreach (var client in new[] { child, guest })
{
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsJsonAsync("/api/dashboard/notes", new SaveDashboardNoteRequest("A", "B"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.PutAsJsonAsync($"/api/dashboard/notes/{note.Id}", new SaveDashboardNoteRequest("A", "B"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.DeleteAsync($"/api/dashboard/notes/{note.Id}", Ct)).StatusCode);
}
await family.DeleteAsync($"/api/dashboard/notes/{note.Id}", Ct);
}
[Fact]
public async Task Notes_are_validated()
{
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _f = family;
Assert.Equal(HttpStatusCode.BadRequest, (await family.PostAsJsonAsync("/api/dashboard/notes", new SaveDashboardNoteRequest("", "Text"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await family.PostAsJsonAsync("/api/dashboard/notes", new SaveDashboardNoteRequest("Titel", ""), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await family.PostAsJsonAsync("/api/dashboard/notes",
new SaveDashboardNoteRequest(new string('t', DashboardNote.TitleMaxLength + 1), "Text"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await family.PostAsJsonAsync("/api/dashboard/notes",
new SaveDashboardNoteRequest("Titel", new string('x', DashboardNote.TextMaxLength + 1)), Ct)).StatusCode);
}
}
@@ -0,0 +1,74 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FamilyPlanner.Domain;
using FamilyPlanner.Shared.Shopping;
namespace FamilyPlanner.IntegrationTests;
/// <summary>Einkaufsliste: „hinzugefügt von …“ und Plausibilitätsprüfung für Kinder (409 + Confirm).</summary>
[Collection(SettingsCollection.Name)]
public sealed class ShoppingPlausibilityTests(AppFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private async Task<Guid> CreateListAsync(HttpClient admin)
{
var response = await admin.PostAsJsonAsync("/api/shopping/lists", new CreateShoppingListRequest(null, $"Plaus {Guid.NewGuid():N}"[..20]), Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<ShoppingListDto>(Ct))!.Id;
}
private static Task<HttpResponseMessage> AddAsync(HttpClient client, Guid listId, string text, bool confirm = false) =>
client.PostAsJsonAsync($"/api/shopping/lists/{listId}/items", new AddShoppingItemRequest(Guid.NewGuid(), text, Confirm: confirm), Ct);
[Fact]
public async Task Child_gets_a_confirmation_request_for_nonsense_and_can_confirm()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _c = child;
var listId = await CreateListAsync(admin);
var rejected = await AddAsync(child, listId, "asdfgh");
Assert.Equal(HttpStatusCode.Conflict, rejected.StatusCode);
using var body = JsonDocument.Parse(await rejected.Content.ReadAsStringAsync(Ct));
Assert.True(body.RootElement.GetProperty(ShoppingProblemExtensions.NeedsConfirmation).GetBoolean());
Assert.Empty(await child.GetFromJsonAsync<List<ShoppingItemDto>>($"/api/shopping/lists/{listId}/items", Ct) ?? []);
var confirmed = await AddAsync(child, listId, "asdfgh", confirm: true);
Assert.Equal(HttpStatusCode.Created, confirmed.StatusCode);
Assert.Equal("Asdfgh", (await confirmed.Content.ReadFromJsonAsync<ShoppingItemDto>(Ct))!.Name);
}
[Fact]
public async Task Normal_items_from_children_and_nonsense_from_parents_are_saved_directly()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _c = child;
using var _f = family;
var listId = await CreateListAsync(admin);
Assert.Equal(HttpStatusCode.Created, (await AddAsync(child, listId, "2x Milch")).StatusCode);
Assert.Equal(HttpStatusCode.Created, (await AddAsync(family, listId, "asdfgh")).StatusCode);
Assert.Equal(HttpStatusCode.Created, (await AddAsync(admin, listId, "???")).StatusCode);
}
[Fact]
public async Task Items_carry_the_name_of_who_added_them()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (user, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _c = child;
var listId = await CreateListAsync(admin);
var added = (await (await AddAsync(child, listId, "Butter")).Content.ReadFromJsonAsync<ShoppingItemDto>(Ct))!;
Assert.Equal(user.DisplayName, added.CreatedByName);
var listed = (await admin.GetFromJsonAsync<List<ShoppingItemDto>>($"/api/shopping/lists/{listId}/items", Ct))!;
Assert.Equal(user.DisplayName, Assert.Single(listed).CreatedByName);
}
}
@@ -14,7 +14,8 @@ public sealed class PermissionMatrixTests
public static TheoryData<Permission, AccessLevel, AccessLevel, AccessLevel, AccessLevel> Requirements => new()
{
{ Permission.DashboardView, F, F, F, R }, // Gast: eingeschränkt
{ Permission.ShoppingRead, F, F, F, F },
{ Permission.DashboardNotesEdit, F, F, N, N }, // Info-Kachel der Startseite: nur Eltern pflegen
{ Permission.ShoppingRead,F, F, F, F },
{ Permission.ShoppingWrite, F, F, F, N }, // Gast: nein (konfigurierbar)
{ Permission.ShoppingDelete, F, F, R, N }, // Kind: nur eigene
{ Permission.MealPlanRead, F, F, F, F },
@@ -0,0 +1,72 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Shopping;
namespace FamilyPlanner.UnitTests.Shopping;
public sealed class ShoppingPlausibilityTests
{
[Theory]
[InlineData("Milch")]
[InlineData("2x Milch")]
[InlineData("500 g Mehl")]
[InlineData("Kaffee")]
[InlineData("Wertstoffsäcke")]
[InlineData("Mississippi-Eis")]
[InlineData("7up")]
[InlineData("Toastbrot")]
[InlineData("Käse")]
[InlineData("Eis")]
[InlineData("")]
public void Plausible_items_are_not_suspicious(string text) =>
Assert.False(ShoppingPlausibility.IsSuspicious(text));
[Theory]
[InlineData("???")]
[InlineData("12345")]
[InlineData("!!!")]
[InlineData("2x 55")]
public void Only_special_characters_or_digits_are_suspicious(string text) =>
Assert.True(ShoppingPlausibility.IsSuspicious(text));
[Theory]
[InlineData("aaaaa")]
[InlineData("Hmmmmm")]
[InlineData("hahaha")]
[InlineData("lolololo")]
[InlineData("ababab")]
public void Repeated_characters_are_suspicious(string text) =>
Assert.True(ShoppingPlausibility.IsSuspicious(text));
[Theory]
[InlineData("asdf")]
[InlineData("ASDFG")]
[InlineData("qwertz")]
[InlineData("hjkl")]
[InlineData("fdsa")]
[InlineData("xyzqwer")]
[InlineData("yxcvbnm")]
public void Keyboard_patterns_are_suspicious(string text) =>
Assert.True(ShoppingPlausibility.IsSuspicious(text));
[Theory]
[InlineData("blabla")]
[InlineData("Keine Ahnung")]
[InlineData("Kacke!")]
[InlineData("TEST")]
[InlineData("3x pups")]
public void Nonsense_words_are_suspicious(string text) =>
Assert.True(ShoppingPlausibility.IsSuspicious(text));
[Fact]
public void Long_letter_runs_without_vowels_are_suspicious() =>
Assert.True(ShoppingPlausibility.IsSuspicious("grtkpfs"));
[Theory]
[InlineData(new[] { Roles.Child }, true)]
[InlineData(new[] { Roles.Family }, false)]
[InlineData(new[] { Roles.Admin }, false)]
[InlineData(new[] { Roles.Guest }, false)]
[InlineData(new[] { Roles.Child, Roles.Family }, false)]
public void Check_applies_to_children_only(string[] roles, bool expected) =>
Assert.Equal(expected, ShoppingPlausibility.AppliesTo(roles));
}
@@ -0,0 +1,41 @@
using FamilyPlanner.Web.Client.Pages;
using FamilyPlanner.Web.Client.Services;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests.Account;
/// <summary>„Meine Einstellungen“ aus dem Benutzermenü: Theme-Wahl und Link zu den Benachrichtigungen.</summary>
public sealed class AccountSettingsPageTests : BunitContext
{
private readonly ThemeState _theme;
public AccountSettingsPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
_theme = new ThemeState(JSInterop.JSRuntime);
Services.AddSingleton(_theme);
}
[Fact]
public void Offers_the_three_theme_choices_and_links_to_notifications()
{
var cut = Render<AccountSettings>();
var labels = cut.FindAll(".fp-theme-choice label").Select(l => l.TextContent.Trim()).ToList();
Assert.Equal(["Wie das Gerät", "Hell", "Dunkel"], labels);
Assert.Equal("konto/benachrichtigungen", cut.Find(".fp-settings-notifications").GetAttribute("href"));
}
[Fact]
public void Choosing_dark_updates_the_theme_state()
{
var cut = Render<AccountSettings>();
cut.FindAll(".fp-theme-choice input")[2].Click();
cut.WaitForAssertion(() => Assert.Equal(ThemePreference.Dark, _theme.Preference));
Assert.True(_theme.IsDarkMode);
}
}
@@ -0,0 +1,161 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Shared.Dashboard;
using FamilyPlanner.Web.Client.Auth;
using FamilyPlanner.Web.Client.Pages;
using FamilyPlanner.Web.Client.Services;
using FamilyPlanner.Web.Tests.Shopping;
using FamilyPlanner.Web.Tests.Tasks;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests.Dashboard;
/// <summary>Allgemeine Info-Kachel der Startseite (Freitext-Notizen, Eltern pflegen).</summary>
public sealed class DashboardNotesTests : BunitContext
{
private static readonly DateOnly Today = new(2026, 10, 12);
private readonly FakeDashboardApi _api = new();
private readonly FakeShoppingApi _shopping = new();
public DashboardNotesTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Home.RefreshDelay = TimeSpan.FromMilliseconds(10);
Services.AddMudServices();
Services.AddSingleton<IDashboardApi>(_api);
Services.AddSingleton<IShoppingApi>(_shopping);
Services.AddSingleton<ILiveConnection, FakeLiveConnection>();
Services.AddSingleton<IAuthService, NoopAuthService>();
Services.AddSingleton<ApiAuthenticationStateProvider>();
Services.AddSingleton<TimeProvider>(new FixedTimeProvider(new DateTimeOffset(2026, 10, 12, 7, 30, 0, TimeSpan.Zero)));
}
private void SignInAs(string role) =>
Services.GetRequiredService<ApiAuthenticationStateProvider>().SetUser(new CurrentUserDto(
Guid.NewGuid(), "test", "Mia", null, "#1E88E5", [role], false, null, null, PermissionMatrix.Effective([role])));
private void Seed(bool canEdit, params DashboardNoteDto[] notes) =>
_api.Data = new DashboardDto(Today, null, null, null, null, null, null, Notes: new DashboardNotesDto(notes, canEdit));
[Fact]
public void Everyone_sees_the_notes_but_only_parents_get_edit_buttons()
{
Seed(canEdit: false, new DashboardNoteDto(Guid.NewGuid(), "Wlan-Passwort", "Sommer2026"));
SignInAs(Roles.Child);
var cut = Render<DashboardHost>();
Assert.Equal("Wlan-Passwort", cut.Find(".fp-dash-note-title").TextContent);
Assert.Equal("Sommer2026", cut.Find(".fp-dash-note-text").TextContent);
Assert.Empty(cut.FindAll(".fp-note-edit"));
Assert.Empty(cut.FindAll(".fp-note-add"));
}
[Fact]
public void Tile_is_hidden_without_notes_for_non_editors()
{
Seed(canEdit: false);
SignInAs(Roles.Guest);
var cut = Render<DashboardHost>();
Assert.Empty(cut.FindAll(".fp-dash-notes"));
}
[Fact]
public void Tile_is_shown_for_parents_even_without_notes()
{
Seed(canEdit: true);
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
Assert.NotEmpty(cut.FindAll(".fp-dash-notes"));
Assert.NotEmpty(cut.FindAll(".fp-note-add"));
}
[Fact]
public void Note_text_is_rendered_as_plain_text()
{
Seed(canEdit: false, new DashboardNoteDto(Guid.NewGuid(), "<b>Titel</b>", "<script>alert(1)</script>"));
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
Assert.Equal("<b>Titel</b>", cut.Find(".fp-dash-note-title").TextContent);
Assert.Empty(cut.FindAll(".fp-dash-notes script"));
Assert.Empty(cut.FindAll(".fp-dash-note-title b"));
}
[Fact]
public void Parent_can_add_a_note_through_the_dialog()
{
Seed(canEdit: true);
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
cut.Find(".fp-note-add").Click();
cut.WaitForElement(".fp-note-title input").Change("Ferien");
cut.Find(".fp-note-text textarea").Change("bis 7. November");
cut.Find(".fp-note-save").Click();
cut.WaitForAssertion(() => Assert.Equal("Ferien", cut.Find(".fp-dash-note-title").TextContent));
var created = Assert.Single(_api.Created);
Assert.Equal(("Ferien", "bis 7. November"), (created.Title, created.Text));
}
[Fact]
public void Dialog_validates_before_saving()
{
Seed(canEdit: true);
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
cut.Find(".fp-note-add").Click();
cut.WaitForElement(".fp-note-save").Click();
cut.WaitForAssertion(() => Assert.Contains("Bitte einen Titel eingeben.", cut.Markup));
Assert.Empty(_api.Created);
}
[Fact]
public void Parent_can_edit_and_delete_a_note()
{
var note = new DashboardNoteDto(Guid.NewGuid(), "Wlan", "alt");
Seed(canEdit: true, note);
SignInAs(Roles.Admin);
var cut = Render<DashboardHost>();
cut.Find(".fp-note-edit").Click();
cut.WaitForElement(".fp-note-text textarea").Change("neu");
cut.Find(".fp-note-save").Click();
cut.WaitForAssertion(() => Assert.Equal("neu", cut.Find(".fp-dash-note-text").TextContent));
Assert.Equal((note.Id, "Wlan", "neu"), (_api.Updated[0].Id, _api.Updated[0].Request.Title, _api.Updated[0].Request.Text));
cut.Find(".fp-note-delete").Click();
cut.WaitForElement(".mud-message-box button.mud-button-filled, .mud-dialog button.mud-button-text");
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Löschen" && b.ClassList.Contains("mud-button-root") && !b.ClassList.Contains("fp-note-delete")).Click();
cut.WaitForAssertion(() => Assert.Equal([note.Id], _api.DeletedNotes));
}
[Fact]
public void Quick_add_asks_children_when_the_server_wants_a_confirmation()
{
_shopping.RejectSuspiciousWithoutConfirm = true;
_api.Data = new DashboardDto(Today, null, null, new DashboardShoppingDto(FakeShoppingApi.SupermarketId, "Supermarkt", 0, 0, true), null, null, null);
SignInAs(Roles.Child);
var cut = Render<DashboardHost>();
var input = cut.Find(".fp-dash-quickadd input");
input.Input("asdf");
input.KeyUp(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Enter" });
cut.WaitForAssertion(() => Assert.Contains("sieht nicht nach einem Artikel aus", cut.Markup));
Assert.Empty(_shopping.Items);
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Ja, hinzufügen").Click();
cut.WaitForAssertion(() => Assert.Single(_shopping.Items));
Assert.True(_shopping.AddRequests[^1].Confirm);
}
}
@@ -16,4 +16,38 @@ internal sealed class FakeDashboardApi : IDashboardApi
Calls++;
return Task.FromResult(Offline ? ApiResult<DashboardDto>.Fail(ApiProblem.Offline) : ApiResult<DashboardDto>.Ok(Data));
}
public List<SaveDashboardNoteRequest> Created { get; } = [];
public List<(Guid Id, SaveDashboardNoteRequest Request)> Updated { get; } = [];
public List<Guid> DeletedNotes { get; } = [];
private List<DashboardNoteDto> Notes => [.. Data.Notes?.Items ?? []];
private void SetNotes(List<DashboardNoteDto> notes) =>
Data = Data with { Notes = new DashboardNotesDto(notes, Data.Notes?.CanEdit ?? false) };
public Task<ApiResult<DashboardNoteDto>> CreateNoteAsync(SaveDashboardNoteRequest request, CancellationToken ct = default)
{
Created.Add(request);
var note = new DashboardNoteDto(Guid.NewGuid(), request.Title, request.Text);
SetNotes([.. Notes, note]);
return Task.FromResult(ApiResult<DashboardNoteDto>.Ok(note));
}
public Task<ApiResult<DashboardNoteDto>> UpdateNoteAsync(Guid id, SaveDashboardNoteRequest request, CancellationToken ct = default)
{
Updated.Add((id, request));
var note = new DashboardNoteDto(id, request.Title, request.Text);
SetNotes(Notes.Select(n => n.Id == id ? note : n).ToList());
return Task.FromResult(ApiResult<DashboardNoteDto>.Ok(note));
}
public Task<ApiResult<bool>> DeleteNoteAsync(Guid id, CancellationToken ct = default)
{
DeletedNotes.Add(id);
SetNotes(Notes.Where(n => n.Id != id).ToList());
return Task.FromResult(ApiResult<bool>.Ok(true));
}
}
@@ -28,15 +28,20 @@ internal sealed class FakeShoppingApi : IShoppingApi
public int AddCalls { get; private set; }
/// <summary>Wie die Api bei Kindern: verdächtige Einträge werden ohne <c>Confirm</c> mit 409 abgelehnt.</summary>
public bool RejectSuspiciousWithoutConfirm { get; set; }
public List<AddShoppingItemRequest> AddRequests { get; } = [];
/// <summary>Ids, deren Löschung mit 403 abgelehnt wird.</summary>
public HashSet<Guid> ForbiddenDeletes { get; } = [];
private int _version;
public ShoppingItemDto Seed(string name, string category, bool done = false, Guid? createdBy = null, Guid? listId = null)
public ShoppingItemDto Seed(string name, string category, bool done = false, Guid? createdBy = null, Guid? listId = null, string? createdByName = null)
{
var item = new ShoppingItemDto(Guid.NewGuid(), listId ?? SupermarketId, name, null, category, done,
done ? DateTimeOffset.UtcNow : null, createdBy, DateTimeOffset.UtcNow.AddMinutes(-Items.Count), NextVersion());
done ? DateTimeOffset.UtcNow : null, createdBy, DateTimeOffset.UtcNow.AddMinutes(-Items.Count), NextVersion(), createdByName);
Items[item.Id] = item;
return item;
}
@@ -60,7 +65,21 @@ internal sealed class FakeShoppingApi : IShoppingApi
public Task<ApiResult<List<ShoppingItemDto>>> GetItemsAsync(Guid listId, CancellationToken ct = default) =>
Result(() => Items.Values.Where(i => i.ListId == listId).ToList());
public Task<ApiResult<ShoppingItemDto>> AddItemAsync(Guid listId, AddShoppingItemRequest request, CancellationToken ct = default) =>
public Task<ApiResult<ShoppingItemDto>> AddItemAsync(Guid listId, AddShoppingItemRequest request, CancellationToken ct = default)
{
if (!Offline)
{
AddRequests.Add(request);
if (RejectSuspiciousWithoutConfirm && !request.Confirm && ShoppingPlausibility.IsSuspicious(request.Text))
{
return Task.FromResult(ApiResult<ShoppingItemDto>.Fail(new ApiProblem { Status = 409, NeedsConfirmation = true, Detail = "Rückfrage nötig" }));
}
}
return AddItemCoreAsync(listId, request);
}
private Task<ApiResult<ShoppingItemDto>> AddItemCoreAsync(Guid listId, AddShoppingItemRequest request) =>
Result<ShoppingItemDto>(() =>
{
AddCalls++;
@@ -0,0 +1,5 @@
@* Testhülle: Dialog-Provider + Einkaufsseite, damit Rückfragen gerendert werden. *@
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<FamilyPlanner.Web.Client.Pages.Shopping />
@@ -138,6 +138,78 @@ public sealed class ShoppingPageTests : BunitContext
Assert.Contains("Brot", cut.Find(".fp-item-name").TextContent);
}
[Fact]
public void Item_shows_who_added_it()
{
_api.Seed("Milch", ShoppingCategories.Dairy, createdBy: UserId, createdByName: "Mia");
_api.Seed("Brot", ShoppingCategories.Bakery);
SignInAs(Roles.Family);
var cut = Render<ShoppingPage>();
var hints = cut.FindAll(".fp-item-addedby").Select(h => h.TextContent.Trim()).ToList();
Assert.Equal(["hinzugefügt von Mia"], hints);
}
[Fact]
public void Child_is_asked_before_nonsense_is_added_and_can_decline()
{
SignInAs(Roles.Child);
var cut = Render<ShoppingHost>();
cut.Find("#shopping-input").Input("asdfgh");
cut.Find("form.fp-quick-add").Submit();
cut.WaitForAssertion(() => Assert.Contains("sieht nicht nach einem Artikel aus", cut.Markup));
Assert.Empty(_api.Items);
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Nein").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("sieht nicht nach einem Artikel aus", cut.Markup));
Assert.Empty(_api.Items);
Assert.Equal("asdfgh", cut.Find("#shopping-input").GetAttribute("value"));
}
[Fact]
public void Child_can_confirm_nonsense_and_it_is_sent_with_confirm()
{
SignInAs(Roles.Child);
var cut = Render<ShoppingHost>();
cut.Find("#shopping-input").Input("asdfgh");
cut.Find("form.fp-quick-add").Submit();
cut.WaitForAssertion(() => Assert.Contains("sieht nicht nach einem Artikel aus", cut.Markup));
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Ja, hinzufügen").Click();
cut.WaitForAssertion(() => Assert.Equal("Asdfgh", Assert.Single(_api.Items.Values).Name));
Assert.True(Assert.Single(_api.AddRequests).Confirm);
}
[Fact]
public void Child_is_not_asked_for_normal_items()
{
SignInAs(Roles.Child);
var cut = Render<ShoppingHost>();
cut.Find("#shopping-input").Input("Milch");
cut.Find("form.fp-quick-add").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Items));
Assert.DoesNotContain("sieht nicht nach einem Artikel aus", cut.Markup);
}
[Fact]
public void Parents_are_never_asked()
{
SignInAs(Roles.Family);
var cut = Render<ShoppingHost>();
cut.Find("#shopping-input").Input("asdfgh");
cut.Find("form.fp-quick-add").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Items));
Assert.DoesNotContain("sieht nicht nach einem Artikel aus", cut.Markup);
}
private sealed class NoopAuthService : IAuthService
{
public Task<CurrentUserDto?> GetCurrentUserAsync(CancellationToken cancellationToken = default) => Task.FromResult<CurrentUserDto?>(null);
@@ -182,4 +182,51 @@ public sealed class ShoppingStoreTests
Assert.Equal(ShoppingCategories.Other, _api.Items.Values.Single().Category);
Assert.Equal(ShoppingCategories.Other, store.Items.Single().Category);
}
[Fact]
public async Task Server_confirmation_request_is_asked_and_resent_with_confirm()
{
_api.RejectSuspiciousWithoutConfirm = true;
var store = CreateStore();
var asked = new List<string>();
store.ConfirmSuspicious = text =>
{
asked.Add(text);
return Task.FromResult(true);
};
await store.InitializeAsync();
await store.AddAsync("asdf", UserId);
Assert.Equal(["asdf"], asked);
Assert.Equal(["Asdf"], _api.Items.Values.Select(i => i.Name));
Assert.True(_api.AddRequests[^1].Confirm);
Assert.Equal(0, store.PendingCount);
}
[Fact]
public async Task Declined_confirmation_drops_the_entry()
{
_api.RejectSuspiciousWithoutConfirm = true;
var store = CreateStore();
store.ConfirmSuspicious = _ => Task.FromResult(false);
await store.InitializeAsync();
await store.AddAsync("asdf", UserId);
Assert.Empty(_api.Items);
Assert.Empty(store.Items);
Assert.Equal(0, store.PendingCount);
}
[Fact]
public async Task Entries_confirmed_up_front_are_sent_with_confirm()
{
var store = CreateStore();
await store.InitializeAsync();
await store.AddAsync("asdf", UserId, "Mia", confirmed: true);
Assert.True(Assert.Single(_api.AddRequests).Confirm);
}
}