merge: Phase 11a small improvements
This commit is contained in:
commit
2ec0d40447
38 files changed
+3758
-40
No files matched your search
@@ -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
@@ -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>();
|
||||
|
||||
+15
@@ -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();
|
||||
}
|
||||
}
|
||||
+2394
File diff suppressed because it is too large.
Load diff
+47
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user